Compare commits

..
10 Commits
Author SHA1 Message Date
yyyyyynnn 01c66e4b23 Update README.md 2026-05-16 23:37:03 +08:00
yyyyyynnn ac8ee327e3 Add files via upload 2026-05-16 21:26:32 +08:00
Dehujiaogeli 81b6595306 Update README with Discord channel information
Added section about Discord channel for GPT information.
2026-05-12 18:19:25 +08:00
dave 1aceaa83fd Update CPA and Discord guidance 2026-05-12 17:13:32 +08:00
Long 59367033cb Add 9router OAuth export support
Adds 9router import/export support while preserving access-token-derived expiration and email-based account names.
2026-05-11 18:54:09 +08:00
dave 6181594dc0 Add Cockpit export format 2026-05-11 02:56:29 +08:00
dave b8bfdfb823 Update usage notes 2026-05-09 20:09:00 +08:00
dave 83f3e60462 Document Discord invite 2026-05-09 18:10:59 +08:00
dave d2398014ea Add Discord invite card 2026-05-09 18:05:27 +08:00
dave 8b628a1a76 Construct CPA id token when missing 2026-05-09 17:33:00 +08:00
2 changed files with 310 additions and 79 deletions
+16 -5
View File
@@ -1,15 +1,22 @@
# ChatGPT Session to CPA / sub2api
# ChatGPT Session to CPA / sub2api / Cockpit / 9router
纯前端单页面工具,用来把 ChatGPT Web 登录 session JSON 转换成 CPAsub2api 可导入 JSON。
纯前端单页面工具,用来把 ChatGPT Web 登录 session JSON 转换成 CPAsub2api、Cockpit Tools 或 9router 可导入 JSON。
## 在线使用
### [**》》 点我直接使用 《《**](https://gtxx3600.github.io/GPTSession2CPAandSub2API/)
### [**》》 点我直接使用 《《**](https://json.chatai.codes/)
## 使用提示
Plus 号可以用此方式导入中转站使用;Free 号的 access token 不能用于调用接口。
本工具可用来解决 Codex OAuth 登录需要绑定手机的问题。Plus 账号通过 Web 登录后的 session 就能生成可导入中转站的账号 JSON 数据;这类数据没有 `refresh_token`,但 `access_token` 有效期通常足够长。
解释一下: plus激活前(free状态)或激活后(plus状态)获取的session在使用上没有区别(free时拿到的session, 激活plus后就可以调模型了),只是账号级别标识有点区别(标识为free or plus),不影响调模型。 换句话讲,不管你啥时候拿到的session, 用本项目转换导入中转站,只要账号当前激活了plus, 就能正常调模型接口。
本工具主要针对 Plus 账号适用,Free 账号即使转换了也没有权限调用 GPT 模型。GoPay 拉闸了,没法每天发 Plus 了;加入 Discord 频道免费获取 GPT 撸羊毛信息,然后配合本工具导入 CPA or Sub2API 使用。
## 支持输入
支持粘贴或拖入 ChatGPT Web session JSON,例如包含:
@@ -21,13 +28,17 @@ Plus 号可以用此方式导入中转站使用;Free 号的 access token 不
- `account.id`
- `account.planType`
也支持粘贴或拖入 9router Codex OAuth JSON,例如包含 `accessToken``refreshToken``expiresAt``providerSpecificData.chatgptAccountId``providerSpecificData.chatgptPlanType`
页面也会尝试从 `accessToken` 的 JWT payload 中补充邮箱、账号 ID、用户 ID、计划类型和过期时间。
## 输出格式
- `CPA`:生成最小 Codex CPA JSON,包含 `type: "codex"``access_token``session_token``email``name`、过期时间等可推导字段
- `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`
ChatGPT Web session 通常不包含 CPA OAuth 文件里常见的 `refresh_token``id_token`,因此 CPA 输出不会伪造这些字段。
- `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 过期后不能自动刷新。
## 本地使用
+294 -74
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatGPT Session -> CPA / sub2api</title>
<title>ChatGPT Session -> CPA / sub2api / Cockpit</title>
<meta name="theme-color" content="#24505a" />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" sizes="any" />
<style>
@@ -90,6 +90,26 @@
letter-spacing: 0;
}
.title-targets {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.title-targets span {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--accent);
font-size: 0.86rem;
font-weight: 800;
}
.meta-row {
display: flex;
flex-wrap: wrap;
@@ -138,7 +158,7 @@
.segmented {
display: inline-grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
width: min(480px, 100%);
padding: 4px;
border: 1px solid var(--line);
@@ -245,30 +265,6 @@
padding: 14px 16px 16px;
}
.dropzone {
display: grid;
min-height: 128px;
place-items: center;
margin-bottom: 12px;
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
background: var(--surface-soft);
color: var(--muted);
text-align: center;
}
.dropzone.is-dragover {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
}
.dropzone strong {
display: block;
margin-bottom: 4px;
color: var(--text);
}
.session-guide {
margin: 0 0 12px;
padding: 13px 14px;
@@ -306,6 +302,71 @@
font-weight: 700;
}
.telegram-card {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px 14px;
align-items: start;
margin-bottom: 12px;
padding: 14px 14px 52px;
border: 1px solid rgba(0, 136, 204, 0.28);
border-radius: var(--radius);
background: #f1f9ff;
color: #173d52;
text-decoration: none;
}
.telegram-card:hover {
border-color: rgba(0, 136, 204, 0.5);
background: #eaf6ff;
}
.telegram-icon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: var(--radius);
background: #0088cc;
color: #fff;
}
.telegram-icon svg {
width: 27px;
height: 27px;
fill: currentColor;
}
.telegram-copy strong {
display: block;
margin-bottom: 4px;
color: #12384d;
font-size: 0.98rem;
line-height: 1.35;
}
.telegram-copy span {
display: block;
color: #3f5f70;
font-size: 0.88rem;
line-height: 1.45;
}
.telegram-action {
position: absolute;
right: 14px;
bottom: 14px;
min-height: 32px;
padding: 6px 12px;
border-radius: 6px;
background: #0088cc;
color: #fff;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 8px 18px rgba(0, 136, 204, 0.22);
}
.input-actions {
display: flex;
flex-wrap: wrap;
@@ -530,6 +591,10 @@
grid-template-columns: 1fr;
}
.telegram-card {
grid-template-columns: auto minmax(0, 1fr);
}
.col-expiry,
.col-type,
td:nth-child(3),
@@ -556,7 +621,13 @@
<header class="topbar">
<div class="title-block">
<p class="eyebrow">Local JSON Converter</p>
<h1>ChatGPT Session -> CPA / sub2api</h1>
<h1>ChatGPT Session Converter</h1>
<div class="title-targets" aria-label="支持输出格式">
<span>CPA</span>
<span>sub2api</span>
<span>Cockpit</span>
<span>9router</span>
</div>
</div>
<div class="meta-row" aria-label="处理方式">
<span class="meta-chip">浏览器本地解析</span>
@@ -564,7 +635,7 @@
<span class="meta-chip">不写入存储</span>
<a
class="meta-chip github-link"
href="https://github.com/gtxx3600/GPTSession2CPAandSub2API"
href="https://github.com/yynxxxxx/GPTSession2CPAandSub2API"
target="_blank"
rel="noreferrer"
aria-label="本页面代码开源"
@@ -581,6 +652,8 @@
<div class="segmented" role="group" aria-label="输出格式">
<button type="button" data-format="sub2api" aria-pressed="true">sub2api</button>
<button type="button" data-format="cpa" aria-pressed="false">CPA</button>
<button type="button" data-format="cockpit" aria-pressed="false">Cockpit</button>
<button type="button" data-format="9router" aria-pressed="false">9router</button>
</div>
<div class="actions">
<button class="button button-secondary" id="copy-output" type="button" disabled>复制输出</button>
@@ -606,12 +679,18 @@
</p>
<p class="session-warning">这段 JSON 包含 accessToken 和 sessionToken,等同敏感登录凭证,不要发给别人。</p>
</div>
<div class="dropzone" id="dropzone" tabindex="0" role="button">
<div>
<strong>拖入 JSON 文件</strong>
<span>也可以点击选择文件</span>
</div>
</div>
<a class="telegram-card" href="https://t.me/Geminivip1" target="_blank" rel="noreferrer">
<span class="telegram-icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M22.44 2.13c.42-.17.85.19.74.63l-3.28 15.47c-.1.49-.66.69-1.05.38l-4.98-3.67-2.4 2.31c-.27.27-.49.49-1.01.49l.36-5.1 9.29-8.4c.4-.36-.09-.56-.63-.2L7.99 11.27 3.04 9.72c-1.08-.34-1.1-1.08.22-1.59l19.18-6Z" />
</svg>
</span>
<span class="telegram-copy">
<strong>加入 Telegram 群免费获取 GPT 撸羊毛信息</strong>
<span>本工具主要针对 Plus 账号适用,Free 账号即使转换了也没有权限调用 GPT 模型。GoPay 拉闸了,没法每天发 Plus 了;加入 Telegram 群免费获取 GPT 撸羊毛信息,然后配合本工具导入 CPA or Sub2API 使用。</span>
</span>
<span class="telegram-action">加入群组</span>
</a>
<input class="hidden" id="file-input" type="file" accept=".json,application/json" multiple />
<div class="input-actions">
<button class="button button-secondary" id="pick-files" type="button">选择文件</button>
@@ -636,7 +715,7 @@
</div>
<div class="panel-body">
<p class="notice" id="cpa-notice">
ChatGPT Web session 一般没有 `refresh_token` `id_token`CPA 输出只包含 access token 和可推导字段
ChatGPT Web session 一般没有 `refresh_token`;缺少真实 `id_token` 时会构造 CPA/Cockpit 可解析的占位 claims,实际调用仍依赖 access token。
</p>
<div class="summary" aria-label="转换统计">
<div class="stat">
@@ -688,6 +767,8 @@
const OUTPUT_LABELS = {
sub2api: "sub2api",
cpa: "CPA",
cockpit: "Cockpit",
"9router": "9router",
};
const state = {
@@ -704,7 +785,6 @@
copyOutput: document.querySelector("#copy-output"),
cpaNotice: document.querySelector("#cpa-notice"),
downloadOutput: document.querySelector("#download-output"),
dropzone: document.querySelector("#dropzone"),
fileInput: document.querySelector("#file-input"),
formatButtons: Array.from(document.querySelectorAll("[data-format]")),
input: document.querySelector("#session-input"),
@@ -765,6 +845,18 @@
return new TextDecoder().decode(bytes);
}
function bytesToBase64Url(bytes) {
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function encodeBase64UrlJson(value) {
return bytesToBase64Url(new TextEncoder().encode(JSON.stringify(value)));
}
function parseJwtPayload(token) {
if (typeof token !== "string" || token.trim() === "") {
return undefined;
@@ -829,6 +921,51 @@
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
function epochSecondsFromValue(value) {
if (value === undefined || value === null || value === "") {
return 0;
}
const numeric = Number(value);
if (Number.isFinite(numeric)) {
return Math.trunc(numeric > 1e11 ? numeric / 1000 : numeric);
}
const parsed = Date.parse(String(value));
return Number.isFinite(parsed) ? Math.trunc(parsed / 1000) : 0;
}
function buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt) {
if (!accountId) {
return undefined;
}
const now = Math.trunc(Date.now() / 1000);
const authInfo = { chatgpt_account_id: accountId };
const expires = epochSecondsFromValue(expiresAt) || now + 90 * 24 * 60 * 60;
if (planType) {
authInfo.chatgpt_plan_type = planType;
}
if (userId) {
authInfo.chatgpt_user_id = userId;
authInfo.user_id = userId;
}
const payload = {
iat: now,
exp: expires,
"https://api.openai.com/auth": authInfo,
};
if (email) {
payload.email = email;
}
return `${encodeBase64UrlJson({ alg: "none", typ: "JWT", cpa_synthetic: true })}.${encodeBase64UrlJson(payload)}.`;
}
function getExpiresIn(expiresAt, now = new Date()) {
if (!expiresAt) {
return undefined;
@@ -927,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;
}
@@ -974,6 +1124,8 @@
record.accessToken,
record.access_token,
record.token?.accessToken,
record.token?.access_token,
record.credentials?.accessToken,
record.credentials?.access_token,
);
if (!accessToken) {
@@ -986,58 +1138,114 @@
record.token?.session_token,
record.credentials?.session_token,
);
const refreshToken = firstNonEmpty(
record.refreshToken,
record.refresh_token,
record.token?.refreshToken,
record.token?.refresh_token,
record.credentials?.refresh_token,
);
const inputIdToken = firstNonEmpty(
record.idToken,
record.id_token,
record.token?.idToken,
record.token?.id_token,
record.credentials?.id_token,
);
const payload = parseJwtPayload(accessToken);
const idPayload = parseJwtPayload(inputIdToken);
const auth = getOpenAIAuthSection(payload);
const idAuth = getOpenAIAuthSection(idPayload);
const profile = getOpenAIProfileSection(payload);
const expiresAt = firstNonEmpty(
payload ? timestampFromUnixSeconds(payload.exp) : undefined,
normalizeTimestamp(record.expires),
normalizeTimestamp(record.expiresAt),
normalizeTimestamp(record.expired),
normalizeTimestamp(record.expires_at),
payload ? timestampFromUnixSeconds(payload.exp) : undefined,
);
const email = firstNonEmpty(
record.user?.email,
record.email,
record.credentials?.email,
record.providerSpecificData?.email,
profile.email,
idPayload?.email,
payload?.email,
);
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,
idAuth.user_id,
);
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,
);
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)
: undefined;
const idToken = firstNonEmpty(inputIdToken, syntheticIdToken);
const cpa = stripUnavailable({
const cpa = Object.fromEntries(Object.entries({
type: "codex",
access_token: accessToken,
session_token: sessionToken,
account_id: accountId,
chatgpt_account_id: accountId,
email,
name,
expired: expiresAt,
last_refresh: exportedAt,
plan_type: planType,
});
chatgpt_plan_type: planType,
id_token: idToken,
id_token_synthetic: Boolean(syntheticIdToken) || undefined,
access_token: accessToken,
refresh_token: refreshToken || "",
session_token: sessionToken,
last_refresh: exportedAt,
expired: expiresAt,
disabled: Boolean(record.disabled) || undefined,
}).filter(([, value]) => value !== undefined && value !== null));
const cockpit = {
type: "codex",
id_token: idToken,
access_token: accessToken,
refresh_token: refreshToken || "",
account_id: accountId,
last_refresh: exportedAt,
email,
expired: expiresAt,
account_note: firstNonEmpty(record.account_note, record.accountInfo, record.account_info, record.note, record.notes, record.remark),
};
const sub2apiAccount = stripUnavailable({
name: firstNonEmpty(name, email, sourceName, "ChatGPT Account"),
@@ -1059,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,
@@ -1071,6 +1303,8 @@
name,
expiresAt,
cpa,
cockpit,
nineRouter,
sub2apiAccount,
};
}
@@ -1095,6 +1329,18 @@
: state.converted.map((item) => item.cpa);
}
if (state.format === "cockpit") {
return state.converted.length === 1
? state.converted[0].cockpit
: 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);
}
@@ -1156,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();
@@ -1337,7 +1583,6 @@
elements.copyOutput.addEventListener("click", copyOutput);
elements.downloadOutput.addEventListener("click", downloadOutput);
elements.pickFiles.addEventListener("click", () => elements.fileInput.click());
elements.dropzone.addEventListener("click", () => elements.fileInput.click());
elements.fileInput.addEventListener("change", (event) => {
readFiles(event.target.files);
event.target.value = "";
@@ -1353,31 +1598,6 @@
scheduleConvert();
});
["dragenter", "dragover"].forEach((eventName) => {
elements.dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
elements.dropzone.classList.add("is-dragover");
});
});
["dragleave", "drop"].forEach((eventName) => {
elements.dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
elements.dropzone.classList.remove("is-dragover");
});
});
elements.dropzone.addEventListener("drop", (event) => {
readFiles(event.dataTransfer.files);
});
elements.dropzone.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
elements.fileInput.click();
}
});
updateOutput();
})();
</script>