account_codex_import.go 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285
  1. package admin
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/base64"
  6. "encoding/hex"
  7. "encoding/json"
  8. "errors"
  9. "fmt"
  10. "io"
  11. "strconv"
  12. "strings"
  13. "time"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  16. "github.com/Wei-Shaw/sub2api/internal/service"
  17. "github.com/gin-gonic/gin"
  18. )
  19. const codexImportClockSkewSeconds int64 = 120
  20. type CodexSessionImportRequest struct {
  21. Content string `json:"content"`
  22. Contents []string `json:"contents"`
  23. Name string `json:"name"`
  24. Notes *string `json:"notes"`
  25. GroupIDs []int64 `json:"group_ids"`
  26. ProxyID *int64 `json:"proxy_id"`
  27. Concurrency *int `json:"concurrency"`
  28. Priority *int `json:"priority"`
  29. RateMultiplier *float64 `json:"rate_multiplier"`
  30. LoadFactor *int `json:"load_factor"`
  31. ExpiresAt *int64 `json:"expires_at"`
  32. AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
  33. CredentialExtras map[string]any `json:"credential_extras"`
  34. Extra map[string]any `json:"extra"`
  35. UpdateExisting *bool `json:"update_existing"`
  36. SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
  37. ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"`
  38. }
  39. type CodexSessionImportResult struct {
  40. Total int `json:"total"`
  41. Created int `json:"created"`
  42. Updated int `json:"updated"`
  43. Skipped int `json:"skipped"`
  44. Failed int `json:"failed"`
  45. Items []CodexSessionImportItem `json:"items,omitempty"`
  46. Warnings []CodexSessionImportMessage `json:"warnings,omitempty"`
  47. Errors []CodexSessionImportMessage `json:"errors,omitempty"`
  48. }
  49. type CodexSessionImportItem struct {
  50. Index int `json:"index"`
  51. Name string `json:"name,omitempty"`
  52. Action string `json:"action"`
  53. AccountID int64 `json:"account_id,omitempty"`
  54. Message string `json:"message,omitempty"`
  55. }
  56. type CodexSessionImportMessage struct {
  57. Index int `json:"index"`
  58. Name string `json:"name,omitempty"`
  59. Message string `json:"message"`
  60. }
  61. type codexImportEntry struct {
  62. Index int
  63. Value any
  64. }
  65. type codexImportAccount struct {
  66. Name string
  67. AccessToken string
  68. RefreshToken string
  69. IDToken string
  70. Email string
  71. AccountID string
  72. UserID string
  73. PlanType string
  74. Organization string
  75. AgentRuntimeID string
  76. AgentPrivateKey string
  77. AgentTaskID string
  78. AgentFedRAMP bool
  79. IsAgentIdentity bool
  80. Credentials map[string]any
  81. Extra map[string]any
  82. TokenExpiresAt *time.Time
  83. IdentityKeys []string
  84. WarningTexts []string
  85. }
  86. type codexJWTClaims struct {
  87. Sub string `json:"sub"`
  88. Email string `json:"email"`
  89. Exp int64 `json:"exp"`
  90. Iat int64 `json:"iat"`
  91. OpenAIAuth *codexJWTOpenAIClaims `json:"https://api.openai.com/auth,omitempty"`
  92. }
  93. type codexJWTOpenAIClaims struct {
  94. ChatGPTAccountID string `json:"chatgpt_account_id"`
  95. ChatGPTUserID string `json:"chatgpt_user_id"`
  96. ChatGPTPlanType string `json:"chatgpt_plan_type"`
  97. UserID string `json:"user_id"`
  98. POID string `json:"poid"`
  99. Organizations []openai.OrganizationClaim `json:"organizations"`
  100. }
  101. type codexAccountIndex struct {
  102. accountsByKey map[string][]service.Account
  103. keysByAccountID map[int64]map[string]struct{}
  104. }
  105. func (h *AccountHandler) ImportCodexSession(c *gin.Context) {
  106. var req CodexSessionImportRequest
  107. if err := c.ShouldBindJSON(&req); err != nil {
  108. response.BadRequest(c, "Invalid request: "+err.Error())
  109. return
  110. }
  111. if err := service.ValidateOpenAILongContextBillingExtra(service.PlatformOpenAI, req.Extra); err != nil {
  112. response.ErrorFrom(c, err)
  113. return
  114. }
  115. if req.Concurrency != nil && *req.Concurrency < 0 {
  116. response.BadRequest(c, "concurrency must be >= 0")
  117. return
  118. }
  119. if req.Priority != nil && *req.Priority < 0 {
  120. response.BadRequest(c, "priority must be >= 0")
  121. return
  122. }
  123. if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
  124. response.BadRequest(c, "rate_multiplier must be >= 0")
  125. return
  126. }
  127. if req.LoadFactor != nil && *req.LoadFactor > 10000 {
  128. response.BadRequest(c, "load_factor must be <= 10000")
  129. return
  130. }
  131. entries, err := parseCodexSessionImportEntries(req)
  132. if err != nil {
  133. response.BadRequest(c, err.Error())
  134. return
  135. }
  136. if len(entries) == 0 {
  137. response.BadRequest(c, "请输入 accessToken 或 Codex session JSON")
  138. return
  139. }
  140. executeAdminIdempotentJSON(c, "admin.accounts.import_codex_session", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
  141. return h.importCodexSessions(ctx, req, entries)
  142. })
  143. }
  144. func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessionImportRequest, entries []codexImportEntry) (CodexSessionImportResult, error) {
  145. result := CodexSessionImportResult{
  146. Total: len(entries),
  147. Items: make([]CodexSessionImportItem, 0, len(entries)),
  148. }
  149. existingAccounts, err := h.listAccountsFiltered(ctx, service.PlatformOpenAI, service.AccountTypeOAuth, "", "", 0, "", "created_at", "desc")
  150. if err != nil {
  151. return result, err
  152. }
  153. index := buildCodexAccountIndex(existingAccounts)
  154. updateExisting := true
  155. if req.UpdateExisting != nil {
  156. updateExisting = *req.UpdateExisting
  157. }
  158. concurrency := 3
  159. if req.Concurrency != nil {
  160. concurrency = *req.Concurrency
  161. }
  162. priority := 50
  163. if req.Priority != nil {
  164. priority = *req.Priority
  165. }
  166. credentialExtras := sanitizeCodexImportCredentialExtras(req.CredentialExtras)
  167. skipDefaultGroupBind := false
  168. if req.SkipDefaultGroupBind != nil {
  169. skipDefaultGroupBind = *req.SkipDefaultGroupBind
  170. }
  171. skipMixedChannelCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
  172. seenIdentity := map[string]codexSeenIdentity{}
  173. for _, entry := range entries {
  174. item, err := normalizeCodexImportEntry(entry)
  175. if err != nil {
  176. result.Failed++
  177. result.Items = append(result.Items, CodexSessionImportItem{
  178. Index: entry.Index,
  179. Action: "failed",
  180. Message: err.Error(),
  181. })
  182. result.Errors = append(result.Errors, CodexSessionImportMessage{
  183. Index: entry.Index,
  184. Message: err.Error(),
  185. })
  186. continue
  187. }
  188. accountName := buildCodexCreateAccountName(req.Name, item, entry.Index, len(entries))
  189. effectiveExpiresAt, credentialExpiresAt, autoPauseOnExpired, expiryWarnings, expiryErr := resolveCodexImportExpiry(req, item)
  190. if expiryErr != nil {
  191. result.Failed++
  192. result.Items = append(result.Items, CodexSessionImportItem{
  193. Index: entry.Index,
  194. Name: accountName,
  195. Action: "failed",
  196. Message: expiryErr.Error(),
  197. })
  198. result.Errors = append(result.Errors, CodexSessionImportMessage{
  199. Index: entry.Index,
  200. Name: accountName,
  201. Message: expiryErr.Error(),
  202. })
  203. continue
  204. }
  205. item.WarningTexts = append(item.WarningTexts, expiryWarnings...)
  206. if credentialExpiresAt != nil {
  207. item.Credentials["expires_at"] = credentialExpiresAt.Format(time.RFC3339)
  208. }
  209. credentials := mergeCodexImportMap(item.Credentials, credentialExtras)
  210. extra := mergeCodexImportMap(req.Extra, item.Extra)
  211. for _, warning := range item.WarningTexts {
  212. result.Warnings = append(result.Warnings, CodexSessionImportMessage{
  213. Index: entry.Index,
  214. Name: accountName,
  215. Message: warning,
  216. })
  217. }
  218. if duplicateIndex, ok := firstSeenCodexIdentity(seenIdentity, item.IdentityKeys, item.UserID); ok {
  219. message := fmt.Sprintf("与第 %d 条导入项重复,已跳过", duplicateIndex)
  220. result.Skipped++
  221. result.Items = append(result.Items, CodexSessionImportItem{
  222. Index: entry.Index,
  223. Name: accountName,
  224. Action: "skipped",
  225. Message: message,
  226. })
  227. result.Warnings = append(result.Warnings, CodexSessionImportMessage{
  228. Index: entry.Index,
  229. Name: accountName,
  230. Message: message,
  231. })
  232. continue
  233. }
  234. markCodexIdentitySeen(seenIdentity, item.IdentityKeys, entry.Index, item.UserID)
  235. existing, matchedKey := index.Find(item.IdentityKeys, item.UserID)
  236. if existing != nil && updateExisting {
  237. if strings.HasPrefix(matchedKey, "account:") && item.UserID != "" &&
  238. codexCredentialString(existing.Credentials, "chatgpt_user_id") == "" {
  239. result.Warnings = append(result.Warnings, CodexSessionImportMessage{
  240. Index: entry.Index,
  241. Name: accountName,
  242. Message: "已有账号未记录 chatgpt_user_id,已按共享的 chatgpt_account_id 匹配并回填,请确认两者属于同一用户",
  243. })
  244. }
  245. preserveExistingRefresh := item.RefreshToken == "" &&
  246. codexCredentialString(existing.Credentials, "refresh_token") != ""
  247. if preserveExistingRefresh {
  248. result.Warnings = append(result.Warnings, CodexSessionImportMessage{
  249. Index: entry.Index,
  250. Name: accountName,
  251. Message: "已有账号包含 refresh_token,本次 accessToken-only 导入已保留自动续期凭据",
  252. })
  253. effectiveExpiresAt = nil
  254. autoPauseOnExpired = nil
  255. }
  256. mergedCredentials := mergeCodexImportCredentials(existing.Credentials, credentials, item)
  257. mergedExtra := mergeCodexImportMap(existing.Extra, extra)
  258. updateInput := &service.UpdateAccountInput{
  259. Credentials: mergedCredentials,
  260. Extra: mergedExtra,
  261. Concurrency: req.Concurrency,
  262. Priority: req.Priority,
  263. RateMultiplier: req.RateMultiplier,
  264. LoadFactor: req.LoadFactor,
  265. ExpiresAt: effectiveExpiresAt,
  266. AutoPauseOnExpired: autoPauseOnExpired,
  267. }
  268. if req.ProxyID != nil {
  269. updateInput.ProxyID = req.ProxyID
  270. }
  271. if len(req.GroupIDs) > 0 {
  272. groupIDs := append([]int64(nil), req.GroupIDs...)
  273. updateInput.GroupIDs = &groupIDs
  274. updateInput.SkipMixedChannelCheck = skipMixedChannelCheck
  275. }
  276. updated, updateErr := h.adminService.UpdateAccount(ctx, existing.ID, updateInput)
  277. if updateErr != nil {
  278. result.Failed++
  279. result.Items = append(result.Items, CodexSessionImportItem{
  280. Index: entry.Index,
  281. Name: accountName,
  282. Action: "failed",
  283. Message: updateErr.Error(),
  284. })
  285. result.Errors = append(result.Errors, CodexSessionImportMessage{
  286. Index: entry.Index,
  287. Name: accountName,
  288. Message: updateErr.Error(),
  289. })
  290. continue
  291. }
  292. if h.tokenCacheInvalidator != nil && updated != nil {
  293. _ = h.tokenCacheInvalidator.InvalidateToken(ctx, updated)
  294. }
  295. result.Updated++
  296. accountID := existing.ID
  297. if updated != nil {
  298. accountID = updated.ID
  299. index.Add(*updated)
  300. }
  301. result.Items = append(result.Items, CodexSessionImportItem{
  302. Index: entry.Index,
  303. Name: accountName,
  304. Action: "updated",
  305. AccountID: accountID,
  306. })
  307. continue
  308. }
  309. account, createErr := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
  310. Name: accountName,
  311. Notes: req.Notes,
  312. Platform: service.PlatformOpenAI,
  313. Type: service.AccountTypeOAuth,
  314. Credentials: credentials,
  315. Extra: extra,
  316. ProxyID: req.ProxyID,
  317. Concurrency: concurrency,
  318. Priority: priority,
  319. RateMultiplier: req.RateMultiplier,
  320. LoadFactor: req.LoadFactor,
  321. GroupIDs: req.GroupIDs,
  322. ExpiresAt: effectiveExpiresAt,
  323. AutoPauseOnExpired: autoPauseOnExpired,
  324. SkipDefaultGroupBind: skipDefaultGroupBind,
  325. SkipMixedChannelCheck: skipMixedChannelCheck,
  326. })
  327. if createErr != nil {
  328. result.Failed++
  329. result.Items = append(result.Items, CodexSessionImportItem{
  330. Index: entry.Index,
  331. Name: accountName,
  332. Action: "failed",
  333. Message: createErr.Error(),
  334. })
  335. result.Errors = append(result.Errors, CodexSessionImportMessage{
  336. Index: entry.Index,
  337. Name: accountName,
  338. Message: createErr.Error(),
  339. })
  340. continue
  341. }
  342. if account != nil {
  343. index.Add(*account)
  344. }
  345. result.Created++
  346. accountID := int64(0)
  347. if account != nil {
  348. accountID = account.ID
  349. }
  350. result.Items = append(result.Items, CodexSessionImportItem{
  351. Index: entry.Index,
  352. Name: accountName,
  353. Action: "created",
  354. AccountID: accountID,
  355. })
  356. }
  357. return result, nil
  358. }
  359. func parseCodexSessionImportEntries(req CodexSessionImportRequest) ([]codexImportEntry, error) {
  360. contents := make([]string, 0, 1+len(req.Contents))
  361. if strings.TrimSpace(req.Content) != "" {
  362. contents = append(contents, req.Content)
  363. }
  364. for _, content := range req.Contents {
  365. if strings.TrimSpace(content) != "" {
  366. contents = append(contents, content)
  367. }
  368. }
  369. var entries []codexImportEntry
  370. for _, content := range contents {
  371. values, err := parseCodexSessionImportContent(content)
  372. if err != nil {
  373. return nil, err
  374. }
  375. for _, value := range values {
  376. entries = append(entries, codexImportEntry{
  377. Index: len(entries) + 1,
  378. Value: value,
  379. })
  380. }
  381. }
  382. return entries, nil
  383. }
  384. func parseCodexSessionImportContent(content string) ([]any, error) {
  385. trimmed := strings.TrimSpace(content)
  386. if trimmed == "" {
  387. return nil, nil
  388. }
  389. if looksLikeJSON(trimmed) {
  390. values, err := decodeCodexJSONStream(trimmed)
  391. if err != nil {
  392. if strings.Contains(trimmed, "\n") {
  393. if lineValues, lineErr := parseCodexSessionImportLines(trimmed); lineErr == nil {
  394. return lineValues, nil
  395. }
  396. }
  397. return nil, fmt.Errorf("JSON 解析失败: %w", err)
  398. }
  399. return flattenCodexImportValues(values), nil
  400. }
  401. return parseCodexSessionImportLines(trimmed)
  402. }
  403. func parseCodexSessionImportLines(content string) ([]any, error) {
  404. values := make([]any, 0)
  405. for _, line := range strings.Split(content, "\n") {
  406. line = strings.TrimSpace(line)
  407. if line == "" {
  408. continue
  409. }
  410. if looksLikeJSON(line) {
  411. lineValues, err := decodeCodexJSONStream(line)
  412. if err != nil {
  413. return nil, fmt.Errorf("第 %d 行 JSON 解析失败: %w", len(values)+1, err)
  414. }
  415. values = append(values, flattenCodexImportValues(lineValues)...)
  416. continue
  417. }
  418. values = append(values, line)
  419. }
  420. return values, nil
  421. }
  422. func decodeCodexJSONStream(content string) ([]any, error) {
  423. decoder := json.NewDecoder(strings.NewReader(content))
  424. decoder.UseNumber()
  425. values := make([]any, 0, 1)
  426. for {
  427. var value any
  428. err := decoder.Decode(&value)
  429. if errors.Is(err, io.EOF) {
  430. break
  431. }
  432. if err != nil {
  433. return nil, err
  434. }
  435. values = append(values, value)
  436. }
  437. if len(values) == 0 {
  438. return nil, errors.New("空 JSON 内容")
  439. }
  440. return values, nil
  441. }
  442. func flattenCodexImportValues(values []any) []any {
  443. out := make([]any, 0, len(values))
  444. var appendValue func(any)
  445. appendValue = func(value any) {
  446. if arr, ok := value.([]any); ok {
  447. for _, item := range arr {
  448. appendValue(item)
  449. }
  450. return
  451. }
  452. out = append(out, value)
  453. }
  454. for _, value := range values {
  455. appendValue(value)
  456. }
  457. return out
  458. }
  459. func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, error) {
  460. now := time.Now().UTC()
  461. item := &codexImportAccount{
  462. Credentials: map[string]any{},
  463. Extra: map[string]any{
  464. "import_source": "codex_session",
  465. "imported_at": now.Format(time.RFC3339),
  466. },
  467. }
  468. switch raw := entry.Value.(type) {
  469. case string:
  470. item.AccessToken = strings.TrimSpace(raw)
  471. case map[string]any:
  472. if agentIdentity, ok := firstCodexMap(raw, []string{"agent_identity"}, []string{"agentIdentity"}); ok || strings.EqualFold(firstCodexString(raw, []string{"auth_mode"}, []string{"authMode"}), service.OpenAIAuthModeAgentIdentity) {
  473. if !ok {
  474. agentIdentity = raw
  475. }
  476. item.IsAgentIdentity = true
  477. item.AgentRuntimeID = firstCodexString(agentIdentity, []string{"agent_runtime_id"}, []string{"agentRuntimeId"})
  478. item.AgentPrivateKey = firstCodexString(agentIdentity, []string{"agent_private_key"}, []string{"agentPrivateKey"})
  479. item.AgentTaskID = firstCodexString(agentIdentity, []string{"task_id"}, []string{"taskId"})
  480. item.AccountID = firstCodexString(agentIdentity, []string{"account_id"}, []string{"accountId"})
  481. item.UserID = firstCodexString(agentIdentity, []string{"chatgpt_user_id"}, []string{"chatgptUserId"})
  482. item.Email = firstCodexString(agentIdentity, []string{"email"})
  483. item.PlanType = firstCodexString(agentIdentity, []string{"plan_type"}, []string{"planType"})
  484. item.AgentFedRAMP = firstCodexBool(agentIdentity, []string{"chatgpt_account_is_fedramp"}, []string{"chatgptAccountIsFedramp"})
  485. if item.AgentRuntimeID == "" || item.AgentPrivateKey == "" || item.AccountID == "" || item.UserID == "" {
  486. return nil, errors.New("agent identity 缺少必要字段")
  487. }
  488. if err := service.ValidateOpenAIAgentIdentityPrivateKey(item.AgentPrivateKey); err != nil {
  489. return nil, errors.New("agent identity private key 格式无效")
  490. }
  491. item.Credentials["auth_mode"] = service.OpenAIAuthModeAgentIdentity
  492. item.Credentials["agent_runtime_id"] = item.AgentRuntimeID
  493. item.Credentials["agent_private_key"] = item.AgentPrivateKey
  494. item.Credentials["chatgpt_account_id"] = item.AccountID
  495. item.Credentials["chatgpt_user_id"] = item.UserID
  496. item.Credentials["chatgpt_account_is_fedramp"] = item.AgentFedRAMP
  497. setCodexCredentialIfNotEmpty(item.Credentials, "task_id", item.AgentTaskID)
  498. setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email)
  499. setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType)
  500. if item.AgentTaskID == "" {
  501. item.WarningTexts = append(item.WarningTexts, "未包含 task_id,首次请求会使用现有 runtime 注册新 task")
  502. }
  503. item.IdentityKeys = buildCodexAgentIdentityKeys(item.AccountID)
  504. item.Name = buildCodexImportAccountName(item, entry.Index)
  505. return item, nil
  506. }
  507. item.AccessToken = firstCodexString(raw,
  508. []string{"tokens", "access_token"},
  509. []string{"tokens", "accessToken"},
  510. []string{"access_token"},
  511. []string{"accessToken"},
  512. []string{"token"},
  513. )
  514. item.RefreshToken = firstCodexString(raw,
  515. []string{"tokens", "refresh_token"},
  516. []string{"tokens", "refreshToken"},
  517. []string{"refresh_token"},
  518. []string{"refreshToken"},
  519. )
  520. item.IDToken = firstCodexString(raw,
  521. []string{"tokens", "id_token"},
  522. []string{"tokens", "idToken"},
  523. []string{"id_token"},
  524. []string{"idToken"},
  525. )
  526. item.Email = firstCodexString(raw, []string{"email"}, []string{"user", "email"})
  527. item.AccountID = firstCodexString(raw,
  528. []string{"chatgpt_account_id"},
  529. []string{"chatgptAccountId"},
  530. []string{"account_id"},
  531. []string{"accountId"},
  532. []string{"account", "id"},
  533. []string{"account", "account_id"},
  534. []string{"account", "chatgpt_account_id"},
  535. )
  536. item.UserID = firstCodexString(raw,
  537. []string{"chatgpt_user_id"},
  538. []string{"chatgptUserId"},
  539. []string{"user_id"},
  540. []string{"userId"},
  541. []string{"user", "id"},
  542. )
  543. item.PlanType = firstCodexString(raw,
  544. []string{"plan_type"},
  545. []string{"planType"},
  546. []string{"account", "plan_type"},
  547. []string{"account", "planType"},
  548. )
  549. item.Organization = firstCodexString(raw,
  550. []string{"organization_id"},
  551. []string{"organizationId"},
  552. []string{"org_id"},
  553. []string{"orgId"},
  554. )
  555. item.Name = firstCodexString(raw, []string{"name"}, []string{"user", "name"})
  556. authProvider := firstCodexString(raw, []string{"auth_provider"}, []string{"authProvider"})
  557. if authProvider != "" {
  558. item.Extra["auth_provider"] = authProvider
  559. }
  560. if sessionToken := firstCodexString(raw, []string{"session_token"}, []string{"sessionToken"}); sessionToken != "" {
  561. item.Extra["session_token_present"] = true
  562. item.WarningTexts = append(item.WarningTexts, "sessionToken 已忽略,不会作为 OAuth refresh_token 存储")
  563. }
  564. if sessionExpiresAt, ok := codexTimeAt(raw, []string{"expires"}); ok {
  565. item.Extra["session_expires_at"] = sessionExpiresAt.Format(time.RFC3339)
  566. }
  567. if tokenExpiresAt, ok := firstCodexTime(raw,
  568. []string{"tokens", "expires_at"},
  569. []string{"tokens", "expiresAt"},
  570. []string{"expires_at"},
  571. []string{"expiresAt"},
  572. ); ok {
  573. if tokenExpiresAt.Unix() <= now.Unix()-codexImportClockSkewSeconds {
  574. return nil, fmt.Errorf("access_token 已过期: %s", tokenExpiresAt.Format(time.RFC3339))
  575. }
  576. item.TokenExpiresAt = &tokenExpiresAt
  577. item.Credentials["expires_at"] = tokenExpiresAt.Format(time.RFC3339)
  578. }
  579. copyCodexExtraString(raw, item.Extra, "user_image", []string{"user", "image"})
  580. copyCodexExtraString(raw, item.Extra, "user_picture", []string{"user", "picture"})
  581. copyCodexExtraString(raw, item.Extra, "account_structure", []string{"account", "structure"})
  582. copyCodexExtraString(raw, item.Extra, "account_residency_region", []string{"account", "residencyRegion"})
  583. copyCodexExtraString(raw, item.Extra, "compute_residency", []string{"account", "computeResidency"})
  584. default:
  585. return nil, fmt.Errorf("第 %d 条格式不支持", entry.Index)
  586. }
  587. if item.IsAgentIdentity {
  588. return item, nil
  589. }
  590. if item.AccessToken == "" {
  591. return nil, errors.New("缺少 accessToken/access_token")
  592. }
  593. item.Credentials["access_token"] = item.AccessToken
  594. if item.RefreshToken != "" {
  595. item.Credentials["refresh_token"] = item.RefreshToken
  596. item.Credentials["client_id"] = openai.ClientID
  597. }
  598. if item.IDToken != "" {
  599. item.Credentials["id_token"] = item.IDToken
  600. _ = enrichCodexImportAccountFromJWT(item, item.IDToken, false, now)
  601. }
  602. if err := enrichCodexImportAccountFromJWT(item, item.AccessToken, true, now); err != nil {
  603. return nil, err
  604. }
  605. if _, ok := item.Credentials["expires_at"]; !ok {
  606. item.WarningTexts = append(item.WarningTexts, "无法从 accessToken 解析过期时间,导入后需自行确认令牌有效性")
  607. }
  608. if item.RefreshToken == "" {
  609. item.WarningTexts = append(item.WarningTexts, "未包含 refresh_token,accessToken 过期后无法自动续期")
  610. }
  611. setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email)
  612. setCodexCredentialIfNotEmpty(item.Credentials, "chatgpt_account_id", item.AccountID)
  613. setCodexCredentialIfNotEmpty(item.Credentials, "chatgpt_user_id", item.UserID)
  614. setCodexCredentialIfNotEmpty(item.Credentials, "organization_id", item.Organization)
  615. setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType)
  616. fingerprint := codexTokenFingerprint(item.AccessToken)
  617. item.Extra["access_token_sha256"] = fingerprint
  618. item.IdentityKeys = buildCodexImportIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken, item.RefreshToken)
  619. item.Name = buildCodexImportAccountName(item, entry.Index)
  620. return item, nil
  621. }
  622. func enrichCodexImportAccountFromJWT(item *codexImportAccount, token string, validateExpiry bool, now time.Time) error {
  623. claims, err := decodeCodexJWTClaims(token)
  624. if err != nil {
  625. if validateExpiry {
  626. item.WarningTexts = append(item.WarningTexts, "accessToken 不是可解析 JWT,无法校验过期时间和账号身份")
  627. }
  628. return nil
  629. }
  630. if validateExpiry && claims.Exp > 0 {
  631. if now.Unix() > claims.Exp+codexImportClockSkewSeconds {
  632. return fmt.Errorf("access_token 已过期: %s", time.Unix(claims.Exp, 0).UTC().Format(time.RFC3339))
  633. }
  634. expiresAt := time.Unix(claims.Exp, 0).UTC()
  635. item.TokenExpiresAt = &expiresAt
  636. item.Credentials["expires_at"] = expiresAt.Format(time.RFC3339)
  637. }
  638. if item.Email == "" {
  639. item.Email = strings.TrimSpace(claims.Email)
  640. }
  641. if claims.OpenAIAuth == nil {
  642. if item.UserID == "" {
  643. item.UserID = strings.TrimSpace(claims.Sub)
  644. }
  645. return nil
  646. }
  647. if item.AccountID == "" {
  648. item.AccountID = strings.TrimSpace(claims.OpenAIAuth.ChatGPTAccountID)
  649. }
  650. if item.UserID == "" {
  651. item.UserID = strings.TrimSpace(claims.OpenAIAuth.ChatGPTUserID)
  652. }
  653. if item.UserID == "" {
  654. item.UserID = strings.TrimSpace(claims.OpenAIAuth.UserID)
  655. }
  656. if item.PlanType == "" {
  657. item.PlanType = strings.TrimSpace(claims.OpenAIAuth.ChatGPTPlanType)
  658. }
  659. if item.Organization == "" {
  660. item.Organization = strings.TrimSpace(claims.OpenAIAuth.POID)
  661. }
  662. if item.Organization == "" {
  663. for _, org := range claims.OpenAIAuth.Organizations {
  664. if org.IsDefault {
  665. item.Organization = org.ID
  666. break
  667. }
  668. }
  669. }
  670. if item.Organization == "" && len(claims.OpenAIAuth.Organizations) > 0 {
  671. item.Organization = claims.OpenAIAuth.Organizations[0].ID
  672. }
  673. if item.UserID == "" {
  674. item.UserID = strings.TrimSpace(claims.Sub)
  675. }
  676. return nil
  677. }
  678. func decodeCodexJWTClaims(token string) (*codexJWTClaims, error) {
  679. parts := strings.Split(token, ".")
  680. if len(parts) != 3 {
  681. return nil, fmt.Errorf("invalid JWT format")
  682. }
  683. payload, err := decodeCodexJWTSegment(parts[1])
  684. if err != nil {
  685. return nil, err
  686. }
  687. var claims codexJWTClaims
  688. if err := json.Unmarshal(payload, &claims); err != nil {
  689. return nil, err
  690. }
  691. return &claims, nil
  692. }
  693. func decodeCodexJWTSegment(segment string) ([]byte, error) {
  694. if decoded, err := base64.RawURLEncoding.DecodeString(segment); err == nil {
  695. return decoded, nil
  696. }
  697. if decoded, err := base64.RawStdEncoding.DecodeString(segment); err == nil {
  698. return decoded, nil
  699. }
  700. padded := segment
  701. if rem := len(padded) % 4; rem > 0 {
  702. padded += strings.Repeat("=", 4-rem)
  703. }
  704. if decoded, err := base64.URLEncoding.DecodeString(padded); err == nil {
  705. return decoded, nil
  706. }
  707. return base64.StdEncoding.DecodeString(padded)
  708. }
  709. func buildCodexImportAccountName(item *codexImportAccount, index int) string {
  710. for _, candidate := range []string{item.Name, item.Email, item.AccountID, item.UserID} {
  711. candidate = strings.TrimSpace(candidate)
  712. if candidate != "" {
  713. return candidate
  714. }
  715. }
  716. return fmt.Sprintf("Codex 导入账号 %d", index)
  717. }
  718. func buildCodexCreateAccountName(base string, item *codexImportAccount, index, total int) string {
  719. base = strings.TrimSpace(base)
  720. if base == "" {
  721. if item == nil {
  722. return fmt.Sprintf("Codex 导入账号 %d", index)
  723. }
  724. return item.Name
  725. }
  726. if total > 1 {
  727. return fmt.Sprintf("%s #%d", base, index)
  728. }
  729. return base
  730. }
  731. func resolveCodexImportExpiry(req CodexSessionImportRequest, item *codexImportAccount) (*int64, *time.Time, *bool, []string, error) {
  732. if item == nil {
  733. return nil, nil, nil, nil, errors.New("导入项为空")
  734. }
  735. // Agent Identity has no OAuth access-token lifetime. Its runtime/task
  736. // lifecycle is handled by the upstream task recovery path, so it must not
  737. // be rejected or auto-paused by the OAuth import expiry policy.
  738. if item.IsAgentIdentity {
  739. return nil, nil, nil, nil, nil
  740. }
  741. var requestExpiresAt *time.Time
  742. if req.ExpiresAt != nil && *req.ExpiresAt > 0 {
  743. t := time.Unix(*req.ExpiresAt, 0).UTC()
  744. requestExpiresAt = &t
  745. }
  746. var accountExpiresAt *time.Time
  747. var credentialExpiresAt *time.Time
  748. warnings := make([]string, 0, 2)
  749. if item.RefreshToken == "" {
  750. if item.TokenExpiresAt != nil {
  751. tokenExpiresAt := item.TokenExpiresAt.UTC()
  752. accountExpiresAt = &tokenExpiresAt
  753. credentialExpiresAt = &tokenExpiresAt
  754. }
  755. if requestExpiresAt != nil {
  756. accountExpiresAt = earlierCodexTime(accountExpiresAt, requestExpiresAt)
  757. credentialExpiresAt = earlierCodexTime(credentialExpiresAt, requestExpiresAt)
  758. }
  759. if accountExpiresAt == nil {
  760. return nil, nil, nil, nil, errors.New("未包含 refresh_token,且无法解析 accessToken 过期时间;请在第一步设置过期时间后再导入")
  761. }
  762. if accountExpiresAt.Unix() <= time.Now().UTC().Unix()-codexImportClockSkewSeconds {
  763. return nil, nil, nil, nil, fmt.Errorf("过期时间已过期: %s", accountExpiresAt.Format(time.RFC3339))
  764. }
  765. warnings = append(warnings, "未包含 refresh_token,已按 accessToken/账号过期时间设置自动停止调度")
  766. if req.AutoPauseOnExpired != nil && !*req.AutoPauseOnExpired {
  767. warnings = append(warnings, "未包含 refresh_token,已强制开启过期自动暂停")
  768. }
  769. autoPause := true
  770. expiresAtUnix := accountExpiresAt.Unix()
  771. return &expiresAtUnix, credentialExpiresAt, &autoPause, warnings, nil
  772. }
  773. if requestExpiresAt != nil {
  774. accountExpiresAt = requestExpiresAt
  775. }
  776. if item.TokenExpiresAt != nil {
  777. tokenExpiresAt := item.TokenExpiresAt.UTC()
  778. credentialExpiresAt = &tokenExpiresAt
  779. }
  780. var expiresAtUnix *int64
  781. if accountExpiresAt != nil {
  782. v := accountExpiresAt.Unix()
  783. expiresAtUnix = &v
  784. }
  785. return expiresAtUnix, credentialExpiresAt, req.AutoPauseOnExpired, warnings, nil
  786. }
  787. func earlierCodexTime(current, candidate *time.Time) *time.Time {
  788. if candidate == nil {
  789. return current
  790. }
  791. if current == nil || candidate.Before(*current) {
  792. t := candidate.UTC()
  793. return &t
  794. }
  795. t := current.UTC()
  796. return &t
  797. }
  798. func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any {
  799. if len(input) == 0 {
  800. return nil
  801. }
  802. protected := map[string]struct{}{
  803. "access_token": {},
  804. "refresh_token": {},
  805. "id_token": {},
  806. "expires_at": {},
  807. "email": {},
  808. "chatgpt_account_id": {},
  809. "chatgpt_user_id": {},
  810. "organization_id": {},
  811. "plan_type": {},
  812. "client_id": {},
  813. "auth_mode": {},
  814. "openai_auth_mode": {},
  815. "token_type": {},
  816. "chatgpt_account_is_fedramp": {},
  817. "agent_runtime_id": {},
  818. "agent_private_key": {},
  819. "task_id": {},
  820. }
  821. out := make(map[string]any, len(input))
  822. for key, value := range input {
  823. normalizedKey := strings.TrimSpace(key)
  824. if normalizedKey == "" {
  825. continue
  826. }
  827. if _, ok := protected[strings.ToLower(normalizedKey)]; ok {
  828. continue
  829. }
  830. out[normalizedKey] = value
  831. }
  832. if len(out) == 0 {
  833. return nil
  834. }
  835. return out
  836. }
  837. // buildCodexImportIdentityKeys 生成导入条目的匹配键。refresh_token 缺失时
  838. // Codex session 只能作为 accessToken-only 凭据使用,此时以 access token
  839. // 指纹作为唯一稳定身份,避免同 workspace 下共享的 account/user 标识误合并。
  840. func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refreshToken string) []string {
  841. accessToken = strings.TrimSpace(accessToken)
  842. refreshToken = strings.TrimSpace(refreshToken)
  843. if refreshToken == "" && accessToken != "" {
  844. return []string{"access:" + codexTokenFingerprint(accessToken)}
  845. }
  846. return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken)
  847. }
  848. func buildCodexAgentIdentityKeys(accountID string) []string {
  849. // Agent Identity credentials belonging to the same ChatGPT account are
  850. // intentionally merged, while the same user may own multiple accounts.
  851. // Do not use user/email/runtime as fallback keys here: user_id is shared
  852. // across Team workspaces and runtime_id changes when a new runtime is
  853. // registered for the same account.
  854. accountID = strings.TrimSpace(accountID)
  855. if accountID == "" {
  856. return nil
  857. }
  858. return []string{"account:" + accountID}
  859. }
  860. // buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度,
  861. // 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。
  862. func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string {
  863. keys := make([]string, 0, 3)
  864. accountID = strings.TrimSpace(accountID)
  865. userID = strings.TrimSpace(userID)
  866. accessToken = strings.TrimSpace(accessToken)
  867. if userID != "" {
  868. keys = append(keys, "user:"+userID)
  869. }
  870. if accountID == "" && userID == "" {
  871. if email = strings.ToLower(strings.TrimSpace(email)); email != "" {
  872. keys = append(keys, "email:"+email)
  873. }
  874. }
  875. if accessToken != "" {
  876. keys = append(keys, "access:"+codexTokenFingerprint(accessToken))
  877. }
  878. if accountID != "" {
  879. keys = append(keys, "account:"+accountID)
  880. }
  881. return keys
  882. }
  883. func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex {
  884. index := &codexAccountIndex{
  885. accountsByKey: map[string][]service.Account{},
  886. keysByAccountID: map[int64]map[string]struct{}{},
  887. }
  888. for _, account := range accounts {
  889. index.Add(account)
  890. }
  891. return index
  892. }
  893. func (i *codexAccountIndex) Add(account service.Account) {
  894. if i == nil {
  895. return
  896. }
  897. if i.accountsByKey == nil {
  898. i.accountsByKey = map[string][]service.Account{}
  899. }
  900. if i.keysByAccountID == nil {
  901. i.keysByAccountID = map[int64]map[string]struct{}{}
  902. }
  903. keys := buildCodexStoredIdentityKeys(
  904. codexCredentialString(account.Credentials, "chatgpt_account_id"),
  905. codexCredentialString(account.Credentials, "chatgpt_user_id"),
  906. codexCredentialString(account.Credentials, "email"),
  907. codexCredentialString(account.Credentials, "access_token"),
  908. )
  909. orderedKeys := make([]string, 0, len(keys)+1)
  910. accountKeys := make(map[string]struct{}, len(keys)+1)
  911. for _, key := range keys {
  912. if _, exists := accountKeys[key]; exists {
  913. continue
  914. }
  915. accountKeys[key] = struct{}{}
  916. orderedKeys = append(orderedKeys, key)
  917. }
  918. if runtimeID := codexCredentialString(account.Credentials, "agent_runtime_id"); runtimeID != "" {
  919. key := "agent:" + runtimeID
  920. if _, exists := accountKeys[key]; !exists {
  921. accountKeys[key] = struct{}{}
  922. orderedKeys = append(orderedKeys, key)
  923. }
  924. }
  925. previousKeys := i.keysByAccountID[account.ID]
  926. for key := range previousKeys {
  927. if _, retained := accountKeys[key]; retained {
  928. i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
  929. continue
  930. }
  931. i.removeFromKey(key, account.ID)
  932. }
  933. for _, key := range orderedKeys {
  934. if _, retained := previousKeys[key]; retained {
  935. continue
  936. }
  937. i.accountsByKey[key] = append(i.accountsByKey[key], account)
  938. }
  939. if len(accountKeys) > 0 {
  940. i.keysByAccountID[account.ID] = accountKeys
  941. return
  942. }
  943. delete(i.keysByAccountID, account.ID)
  944. }
  945. func (i *codexAccountIndex) removeFromKey(key string, accountID int64) {
  946. accounts := i.accountsByKey[key]
  947. kept := accounts[:0]
  948. for _, account := range accounts {
  949. if account.ID != accountID {
  950. kept = append(kept, account)
  951. }
  952. }
  953. if len(kept) == 0 {
  954. delete(i.accountsByKey, key)
  955. return
  956. }
  957. i.accountsByKey[key] = kept
  958. }
  959. // upsertCodexAccount keeps all candidates for shared keys while replacing an
  960. // existing account in place so ambiguous legacy matches retain their order.
  961. func upsertCodexAccount(accounts []service.Account, account service.Account) []service.Account {
  962. for idx := range accounts {
  963. if accounts[idx].ID == account.ID {
  964. accounts[idx] = account
  965. return accounts
  966. }
  967. }
  968. return append(accounts, account)
  969. }
  970. // Find 返回第一个通过跨用户校验的候选账号及其命中的匹配键。
  971. func (i *codexAccountIndex) Find(keys []string, userID string) (*service.Account, string) {
  972. if i == nil {
  973. return nil, ""
  974. }
  975. for _, key := range keys {
  976. for _, account := range i.accountsByKey[key] {
  977. if codexIdentityConflicts(key, userID, codexCredentialString(account.Credentials, "chatgpt_user_id")) {
  978. continue
  979. }
  980. return &account, key
  981. }
  982. }
  983. return nil, ""
  984. }
  985. // codexIdentityConflicts 判断 account: 键的命中是否把同一 ChatGPT 团队的两个
  986. // 不同成员误连到一起:双方都携带 user id 且不相等时视为冲突。存量索引侧
  987. // 仍保留 account 键,任一侧缺少 user id 时允许匹配,使含 refresh_token
  988. // 的常规导入和 accessToken-only 账号升级为完整 OAuth 时仍能更新原账号。
  989. func codexIdentityConflicts(key, userID, storedUserID string) bool {
  990. if !strings.HasPrefix(key, "account:") {
  991. return false
  992. }
  993. userID = strings.TrimSpace(userID)
  994. storedUserID = strings.TrimSpace(storedUserID)
  995. return userID != "" && storedUserID != "" && userID != storedUserID
  996. }
  997. type codexSeenIdentity struct {
  998. index int
  999. userID string
  1000. }
  1001. func firstSeenCodexIdentity(seen map[string]codexSeenIdentity, keys []string, userID string) (int, bool) {
  1002. for _, key := range keys {
  1003. entry, ok := seen[key]
  1004. if !ok {
  1005. continue
  1006. }
  1007. if codexIdentityConflicts(key, userID, entry.userID) {
  1008. continue
  1009. }
  1010. return entry.index, true
  1011. }
  1012. return 0, false
  1013. }
  1014. func markCodexIdentitySeen(seen map[string]codexSeenIdentity, keys []string, index int, userID string) {
  1015. for _, key := range keys {
  1016. seen[key] = codexSeenIdentity{index: index, userID: userID}
  1017. }
  1018. }
  1019. func mergeCodexImportMap(existing, incoming map[string]any) map[string]any {
  1020. out := make(map[string]any, len(existing)+len(incoming))
  1021. for k, v := range existing {
  1022. out[k] = v
  1023. }
  1024. for k, v := range incoming {
  1025. out[k] = v
  1026. }
  1027. return out
  1028. }
  1029. func mergeCodexImportCredentials(existing, incoming map[string]any, item *codexImportAccount) map[string]any {
  1030. out := mergeCodexImportMap(existing, incoming)
  1031. if item == nil {
  1032. return out
  1033. }
  1034. if strings.TrimSpace(item.RefreshToken) == "" {
  1035. if codexCredentialString(existing, "refresh_token") == "" {
  1036. delete(out, "refresh_token")
  1037. delete(out, "client_id")
  1038. } else {
  1039. out["refresh_token"] = existing["refresh_token"]
  1040. if clientID, ok := existing["client_id"]; ok {
  1041. out["client_id"] = clientID
  1042. }
  1043. }
  1044. }
  1045. if strings.TrimSpace(item.IDToken) == "" {
  1046. delete(out, "id_token")
  1047. }
  1048. return out
  1049. }
  1050. func codexCredentialString(credentials map[string]any, key string) string {
  1051. if credentials == nil {
  1052. return ""
  1053. }
  1054. return codexStringValue(credentials[key])
  1055. }
  1056. func codexTokenFingerprint(token string) string {
  1057. sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
  1058. return hex.EncodeToString(sum[:])
  1059. }
  1060. func looksLikeJSON(content string) bool {
  1061. if content == "" {
  1062. return false
  1063. }
  1064. switch content[0] {
  1065. case '{', '[':
  1066. return true
  1067. default:
  1068. return false
  1069. }
  1070. }
  1071. func firstCodexString(obj map[string]any, paths ...[]string) string {
  1072. for _, path := range paths {
  1073. if value, ok := codexPathValue(obj, path); ok {
  1074. if str := codexStringValue(value); str != "" {
  1075. return str
  1076. }
  1077. }
  1078. }
  1079. return ""
  1080. }
  1081. func firstCodexMap(obj map[string]any, paths ...[]string) (map[string]any, bool) {
  1082. for _, path := range paths {
  1083. value, ok := codexPathValue(obj, path)
  1084. if !ok || value == nil {
  1085. continue
  1086. }
  1087. if mapped, ok := value.(map[string]any); ok {
  1088. return mapped, true
  1089. }
  1090. }
  1091. return nil, false
  1092. }
  1093. func firstCodexBool(obj map[string]any, paths ...[]string) bool {
  1094. for _, path := range paths {
  1095. value, ok := codexPathValue(obj, path)
  1096. if !ok {
  1097. continue
  1098. }
  1099. switch value := value.(type) {
  1100. case bool:
  1101. return value
  1102. case string:
  1103. parsed, err := strconv.ParseBool(strings.TrimSpace(value))
  1104. if err == nil {
  1105. return parsed
  1106. }
  1107. }
  1108. }
  1109. return false
  1110. }
  1111. func copyCodexExtraString(obj map[string]any, extra map[string]any, key string, path []string) {
  1112. value := firstCodexString(obj, path)
  1113. if value != "" {
  1114. extra[key] = value
  1115. }
  1116. }
  1117. func firstCodexTime(obj map[string]any, paths ...[]string) (time.Time, bool) {
  1118. for _, path := range paths {
  1119. if value, ok := codexTimeAt(obj, path); ok {
  1120. return value, true
  1121. }
  1122. }
  1123. return time.Time{}, false
  1124. }
  1125. func codexTimeAt(obj map[string]any, path []string) (time.Time, bool) {
  1126. value, ok := codexPathValue(obj, path)
  1127. if !ok {
  1128. return time.Time{}, false
  1129. }
  1130. return parseCodexTimeValue(value)
  1131. }
  1132. func codexPathValue(obj map[string]any, path []string) (any, bool) {
  1133. var current any = obj
  1134. for _, key := range path {
  1135. currentObj, ok := current.(map[string]any)
  1136. if !ok {
  1137. return nil, false
  1138. }
  1139. value, ok := currentObj[key]
  1140. if !ok {
  1141. return nil, false
  1142. }
  1143. current = value
  1144. }
  1145. return current, true
  1146. }
  1147. func codexStringValue(value any) string {
  1148. switch v := value.(type) {
  1149. case string:
  1150. return strings.TrimSpace(v)
  1151. case json.Number:
  1152. return strings.TrimSpace(v.String())
  1153. case float64:
  1154. return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64))
  1155. case float32:
  1156. return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32))
  1157. case int:
  1158. return strconv.Itoa(v)
  1159. case int64:
  1160. return strconv.FormatInt(v, 10)
  1161. case int32:
  1162. return strconv.FormatInt(int64(v), 10)
  1163. default:
  1164. return ""
  1165. }
  1166. }
  1167. func setCodexCredentialIfNotEmpty(credentials map[string]any, key, value string) {
  1168. value = strings.TrimSpace(value)
  1169. if value != "" {
  1170. credentials[key] = value
  1171. }
  1172. }
  1173. func parseCodexTimeValue(value any) (time.Time, bool) {
  1174. switch v := value.(type) {
  1175. case string:
  1176. v = strings.TrimSpace(v)
  1177. if v == "" {
  1178. return time.Time{}, false
  1179. }
  1180. if parsed, err := time.Parse(time.RFC3339Nano, v); err == nil {
  1181. return parsed.UTC(), true
  1182. }
  1183. if n, err := strconv.ParseInt(v, 10, 64); err == nil {
  1184. return codexUnixTime(n), true
  1185. }
  1186. case json.Number:
  1187. if n, err := v.Int64(); err == nil {
  1188. return codexUnixTime(n), true
  1189. }
  1190. if f, err := v.Float64(); err == nil {
  1191. return codexUnixTime(int64(f)), true
  1192. }
  1193. case float64:
  1194. return codexUnixTime(int64(v)), true
  1195. case int:
  1196. return codexUnixTime(int64(v)), true
  1197. case int64:
  1198. return codexUnixTime(v), true
  1199. }
  1200. return time.Time{}, false
  1201. }
  1202. func codexUnixTime(value int64) time.Time {
  1203. if value > 1_000_000_000_000 {
  1204. return time.UnixMilli(value).UTC()
  1205. }
  1206. return time.Unix(value, 0).UTC()
  1207. }