diff --git a/README.md b/README.md
index 64ab0c0..7fe7c68 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# ChatGPT Session to CPA / sub2api / Cockpit
+# ChatGPT Session to CPA / sub2api / Cockpit / 9router
-纯前端单页面工具,用来把 ChatGPT Web 登录 session JSON 转换成 CPA、sub2api 或 Cockpit Tools 可导入 JSON。
+纯前端单页面工具,用来把 ChatGPT Web 登录 session JSON 转换成 CPA、sub2api、Cockpit Tools 或 9router 可导入 JSON。
## 在线使用
@@ -34,6 +34,8 @@ Plus 号可以用此方式导入中转站使用;Free 号的 access token 不
- `account.id`
- `account.planType`
+也支持粘贴或拖入 9router Codex OAuth JSON,例如包含 `accessToken`、`refreshToken`、`expiresAt`、`providerSpecificData.chatgptAccountId` 和 `providerSpecificData.chatgptPlanType`。
+
页面也会尝试从 `accessToken` 的 JWT payload 中补充邮箱、账号 ID、用户 ID、计划类型和过期时间。
## 输出格式
@@ -41,6 +43,7 @@ Plus 号可以用此方式导入中转站使用;Free 号的 access token 不
- `CPA`:生成 Codex CPA auth JSON,包含 `type: "codex"`、`access_token`、`session_token`、`id_token`、`email`、`account_id`、套餐和过期时间等字段;缺少真实 `id_token` 时会根据 session 与 access token claims 构造 CPA 可解析的占位 claims。
- `sub2api`:生成参考 `CPA2sub2API` 项目的 `exported_at/proxies/accounts` 结构,账号平台为 `openai`,类型为 `oauth`。
- `Cockpit`:生成 Cockpit Tools Codex JSON 导入可识别的扁平 token 格式,包含 `id_token`、`access_token`、`refresh_token`、`account_id`、`email`、`expired` 等字段。
+- `9router`:生成 9router Codex OAuth JSON,包含 `accessToken`、`refreshToken`、`expiresAt`、`providerSpecificData`、`provider`、`authType`、`priority`、`isActive`、`createdAt` 和 `updatedAt` 等字段。
ChatGPT Web session 通常不包含 CPA OAuth 文件里常见的 `refresh_token`,因此 access token 过期后不能自动刷新。
## 本地使用
diff --git a/docs/index.html b/docs/index.html
index b3be453..d75549c 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -158,7 +158,7 @@
.segmented {
display: inline-grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-columns: repeat(4, minmax(0, 1fr));
width: min(480px, 100%);
padding: 4px;
border: 1px solid var(--line);
@@ -626,6 +626,7 @@
CPA
sub2api
Cockpit
+ 9router
@@ -652,6 +653,7 @@
+
@@ -766,6 +768,7 @@
sub2api: "sub2api",
cpa: "CPA",
cockpit: "Cockpit",
+ "9router": "9router",
};
const state = {
@@ -1061,9 +1064,22 @@
}
visited.add(item);
- const token = firstNonEmpty(item.accessToken, item.access_token, item.token?.accessToken, item.credentials?.access_token);
- const hasUser = isPlainObject(item.user) || firstNonEmpty(item.email, item.name);
- if (token && hasUser) {
+ const token = firstNonEmpty(
+ item.accessToken,
+ item.access_token,
+ item.token?.accessToken,
+ item.token?.access_token,
+ item.credentials?.accessToken,
+ item.credentials?.access_token,
+ );
+ const hasIdentity = isPlainObject(item.user) || firstNonEmpty(
+ item.email,
+ item.name,
+ item.providerSpecificData?.chatgptAccountId,
+ item.providerSpecificData?.chatgpt_account_id,
+ item.id,
+ );
+ if (token && hasIdentity) {
found.push({ value: item, sourceName, path });
return;
}
@@ -1108,6 +1124,8 @@
record.accessToken,
record.access_token,
record.token?.accessToken,
+ record.token?.access_token,
+ record.credentials?.accessToken,
record.credentials?.access_token,
);
if (!accessToken) {
@@ -1143,6 +1161,7 @@
const expiresAt = firstNonEmpty(
payload ? timestampFromUnixSeconds(payload.exp) : undefined,
normalizeTimestamp(record.expires),
+ normalizeTimestamp(record.expiresAt),
normalizeTimestamp(record.expired),
normalizeTimestamp(record.expires_at),
);
@@ -1150,6 +1169,7 @@
record.user?.email,
record.email,
record.credentials?.email,
+ record.providerSpecificData?.email,
profile.email,
idPayload?.email,
payload?.email,
@@ -1157,13 +1177,20 @@
const accountId = firstNonEmpty(
record.account?.id,
record.account_id,
+ record.chatgptAccountId,
+ record.providerSpecificData?.chatgptAccountId,
+ record.providerSpecificData?.chatgpt_account_id,
record.credentials?.chatgpt_account_id,
auth.chatgpt_account_id,
idAuth.chatgpt_account_id,
+ record.provider === "codex" ? record.id : undefined,
);
const userId = firstNonEmpty(
record.user?.id,
record.user_id,
+ record.chatgptUserId,
+ record.providerSpecificData?.chatgptUserId,
+ record.providerSpecificData?.chatgpt_user_id,
auth.chatgpt_user_id,
auth.user_id,
idAuth.chatgpt_user_id,
@@ -1172,7 +1199,10 @@
const planType = firstNonEmpty(
record.account?.planType,
record.account?.plan_type,
+ record.planType,
record.plan_type,
+ record.providerSpecificData?.chatgptPlanType,
+ record.providerSpecificData?.chatgpt_plan_type,
record.credentials?.plan_type,
auth.chatgpt_plan_type,
idAuth.chatgpt_plan_type,
@@ -1180,6 +1210,7 @@
const exportedAt = normalizeTimestamp(options.now || new Date());
const expiresIn = getExpiresIn(expiresAt, options.now || new Date());
const sourceName = firstNonEmpty(options.sourceName, "pasted-json");
+ const sourceType = record.provider === "codex" && record.authType === "oauth" ? "9router" : "chatgpt_web_session";
const name = firstNonEmpty(email, sourceName, "ChatGPT Account");
const syntheticIdToken = !inputIdToken
? buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt)
@@ -1236,10 +1267,34 @@
email_key: toEmailKey(email),
name,
auth_provider: firstNonEmpty(record.authProvider, record.auth_provider),
- source: "chatgpt_web_session",
+ source: sourceType,
last_refresh: exportedAt,
},
});
+ const priority = Number.isFinite(Number(record.priority)) ? Number(record.priority) : 9;
+ const isActive = typeof record.isActive === "boolean" ? record.isActive : !Boolean(record.disabled);
+ const createdAt = normalizeTimestamp(record.createdAt) || exportedAt;
+ const updatedAt = normalizeTimestamp(record.updatedAt) || exportedAt;
+ const nineRouter = stripUnavailable({
+ accessToken,
+ refreshToken,
+ expiresAt,
+ testStatus: firstNonEmpty(record.testStatus, record.test_status, "active"),
+ expiresIn,
+ providerSpecificData: {
+ chatgptAccountId: accountId,
+ chatgptPlanType: planType,
+ },
+ id: accountId,
+ provider: "codex",
+ authType: "oauth",
+ name,
+ email,
+ priority,
+ isActive,
+ createdAt,
+ updatedAt,
+ });
return {
sourceName,
@@ -1249,6 +1304,7 @@
expiresAt,
cpa,
cockpit,
+ nineRouter,
sub2apiAccount,
};
}
@@ -1279,6 +1335,12 @@
: state.converted.map((item) => item.cockpit);
}
+ if (state.format === "9router") {
+ return state.converted.length === 1
+ ? state.converted[0].nineRouter
+ : state.converted.map((item) => item.nineRouter);
+ }
+
return buildSub2apiDocument(state.converted, now);
}
@@ -1340,7 +1402,7 @@
elements.statErrors.textContent = String(state.skipped.length);
elements.statFormat.textContent = OUTPUT_LABELS[state.format];
elements.outputSubtitle.textContent = `当前输出为 ${OUTPUT_LABELS[state.format]} 导入 JSON。`;
- elements.cpaNotice.style.display = state.format === "sub2api" ? "none" : "block";
+ elements.cpaNotice.style.display = state.format === "cpa" || state.format === "cockpit" ? "block" : "none";
renderAccounts();
renderIssues();