/*
* ChatGPT Team/Business single-seat cost checker.
*
* Usage:
* 1. Sign in at https://chatgpt.com/ with the workspace owner account.
* 2. Paste this entire file into DevTools Console and run it.
*
* This script only calls the subscription preview endpoint. It never submits
* a seat update or changes the subscription.
*/
(async () => {
"use strict";
const CHATGPT_ORIGIN = "https://chatgpt.com";
const AUTH_CLAIM = "https://api.openai.com/auth";
const DIALOG_ID = "team-workflow-seat-cost-dialog";
const ZERO_DECIMAL_CURRENCIES = new Set([
"BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA",
"PYG", "RWF", "VND", "VUV", "XAF", "XOF", "XPF",
]);
const THREE_DECIMAL_CURRENCIES = new Set([
"BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND",
]);
const FOUR_DECIMAL_CURRENCIES = new Set(["CLF", "UYW"]);
function firstText(...values) {
for (const value of values) {
if (value !== undefined && value !== null && String(value).trim()) {
return String(value).trim();
}
}
return "";
}
function decodeJwtClaims(token) {
const part = String(token || "").split(".")[1];
if (!part) return {};
try {
const normalized = part.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
const bytes = Uint8Array.from(atob(padded), (value) => value.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
} catch (_) {
return {};
}
}
function authContext(session) {
const account = session?.account && typeof session.account === "object"
? session.account
: {};
const claims = decodeJwtClaims(session?.accessToken);
const auth = claims?.[AUTH_CLAIM] && typeof claims[AUTH_CLAIM] === "object"
? claims[AUTH_CLAIM]
: {};
return {
accessToken: firstText(session?.accessToken, session?.access_token),
accountId: firstText(
auth.chatgpt_account_id,
auth.poid,
session?.chatgpt_account_id,
session?.account_id,
session?.workspace_id,
account.id,
account.account_id,
),
};
}
function cookieValue(name) {
const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : "";
}
function setCookie(name, value) {
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Secure; SameSite=Lax`;
}
function clearCookie(name) {
document.cookie = `${name}=; Path=/; Max-Age=0; Secure; SameSite=Lax`;
}
async function requestJson(url, options = {}, label = "请求") {
const response = await fetch(url, {
credentials: "include",
cache: "no-store",
...options,
headers: {
Accept: "application/json",
...(options.headers || {}),
},
});
const text = await response.text();
let data = {};
if (text.trim()) {
try {
data = JSON.parse(text);
} catch (_) {
data = {};
}
}
if (!response.ok) {
const error = data?.error && typeof data.error === "object" ? data.error : {};
const detail = firstText(
error.message,
error.code,
data?.message,
data?.detail,
text.slice(0, 240),
);
throw new Error(`${label}失败:HTTP ${response.status}${detail ? ` · ${detail}` : ""}`);
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw new Error(`${label}失败:响应不是 JSON 对象`);
}
return data;
}
function workspaceRows(payload) {
const accounts = payload?.accounts && typeof payload.accounts === "object"
? payload.accounts
: {};
const ordering = Array.isArray(payload?.account_ordering)
? payload.account_ordering.map(String)
: Object.keys(accounts);
const ids = [...new Set([...ordering, ...Object.keys(accounts)])];
return ids.map((key) => {
const wrapper = accounts[key] && typeof accounts[key] === "object"
? accounts[key]
: {};
const account = wrapper.account && typeof wrapper.account === "object"
? wrapper.account
: wrapper;
return {
id: firstText(account.account_id, account.id, wrapper.account_id, key),
name: firstText(
account.name,
account.workspace_name,
wrapper.name,
wrapper.workspace_name,
key,
),
structure: firstText(account.structure, wrapper.structure).toLowerCase(),
deactivated: account.is_deactivated === true || wrapper.is_deactivated === true,
};
}).filter((row) => row.id && !row.deactivated);
}
function chooseWorkspace(rows, currentId) {
const current = rows.find((row) => row.id === currentId);
if (current && current.structure && current.structure !== "personal") return current;
const workspaces = rows.filter((row) => row.structure !== "personal");
if (workspaces.length === 1) return workspaces[0];
if (!workspaces.length) {
throw new Error("当前账号没有可用的 Team/Business Workspace");
}
const options = workspaces
.map((row, index) => `${index + 1}. ${row.name} (${row.id})`)
.join("\n");
const answer = window.prompt(`请选择要检测的 Workspace:\n\n${options}`, "1");
if (answer === null) throw new Error("已取消检测");
const index = Number(answer) - 1;
if (!Number.isInteger(index) || !workspaces[index]) {
throw new Error("Workspace 序号无效");
}
return workspaces[index];
}
async function workspaceSession(workspaceId, initialSession) {
const initial = authContext(initialSession);
if (initial.accountId === workspaceId && initial.accessToken) return initial;
const query = new URLSearchParams({
exchange_workspace_token: "true",
workspace_id: workspaceId,
reason: "setCurrentAccount",
});
const contextCookies = [
["_account", workspaceId],
["_account_is_fedramp", "false"],
["_account_residency_region", "no_constraint"],
];
const previousCookies = new Map(
contextCookies.map(([name]) => [name, cookieValue(name)]),
);
let session;
try {
for (const [name, value] of contextCookies) setCookie(name, value);
session = await requestJson(
`/api/auth/session?${query}`,
{
headers: {
"X-OpenAI-Target-Path": "/api/auth/session",
"X-OpenAI-Target-Route": "/api/auth/session",
},
},
"切换 Workspace 上下文",
);
} finally {
for (const [name] of contextCookies) {
const previous = previousCookies.get(name);
if (previous) setCookie(name, previous);
else clearCookie(name);
}
}
const context = authContext(session);
if (!context.accessToken) throw new Error("Workspace 会话未返回 Access Token");
if (context.accountId && context.accountId !== workspaceId) {
throw new Error(`Workspace 切换结果不匹配:${context.accountId}`);
}
return {...context, accountId: workspaceId};
}
async function previewSeatCost(accessToken, workspaceId, updatedSeats) {
const query = new URLSearchParams({
account_id: workspaceId,
updated_seats: String(updatedSeats),
});
return requestJson(
`/backend-api/subscriptions/update/preview?${query}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"chatgpt-account-id": workspaceId,
},
},
"读取席位费用",
);
}
async function singleSeatPreview(accessToken, workspaceId) {
let targetSeats = 3;
for (let attempt = 0; attempt < 3; attempt += 1) {
const preview = await previewSeatCost(accessToken, workspaceId, targetSeats);
const currentSeats = Number(preview?.current_seat_quantity);
if (!Number.isInteger(currentSeats) || currentSeats < 1) {
throw new Error("费用预览未返回当前席位数");
}
const desiredSeats = currentSeats + 1;
if (targetSeats === desiredSeats) {
return {preview, currentSeats, updatedSeats: targetSeats};
}
targetSeats = desiredSeats;
}
throw new Error("检测期间当前席位数连续变化,请稍后重试");
}
function currencyMinorUnit(currency) {
if (ZERO_DECIMAL_CURRENCIES.has(currency)) return 0;
if (THREE_DECIMAL_CURRENCIES.has(currency)) return 3;
if (FOUR_DECIMAL_CURRENCIES.has(currency)) return 4;
return 2;
}
function previewAmount(preview) {
const amountDue = preview?.amount_due && typeof preview.amount_due === "object"
? preview.amount_due
: {};
const rawAmount = amountDue.amount ?? preview?.total_amount;
const currency = firstText(preview?.currency, amountDue.currency).toUpperCase();
if (rawAmount === undefined || rawAmount === null || !/^[A-Z]{3}$/.test(currency)) {
throw new Error("费用预览未返回有效的金额或币种");
}
const minorUnit = currencyMinorUnit(currency);
const amount = Number(rawAmount) / 10 ** minorUnit;
if (!Number.isFinite(amount)) throw new Error("费用预览金额无效");
let formatted;
try {
formatted = new Intl.NumberFormat("zh-CN", {
style: "currency",
currency,
minimumFractionDigits: minorUnit,
maximumFractionDigits: minorUnit,
}).format(amount);
} catch (_) {
formatted = `${currency} ${amount.toFixed(minorUnit)}`;
}
return {amount, currency, minorUnit, formatted};
}
function billingTime(value) {
const raw = firstText(value);
if (!raw) return {formatted: "未返回", raw: ""};
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return {formatted: raw, raw};
return {
formatted: new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date),
raw,
};
}
function showDialog({title, tone = "normal", rows}) {
document.getElementById(DIALOG_ID)?.remove();
const dialog = document.createElement("dialog");
dialog.id = DIALOG_ID;
dialog.style.cssText = [
"width:min(460px,calc(100% - 40px))", "max-width:460px", "padding:0",
"border:0", "border-radius:8px", "background:transparent", "overflow:visible",
"z-index:2147483647",
"font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
].join(";");
const panel = document.createElement("section");
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.style.cssText = [
"width:min(460px,100%)", "background:#fff", "color:#171717",
"border:1px solid #d8d8d8", "border-radius:8px",
"box-shadow:0 24px 70px rgba(0,0,0,.32)", "overflow:hidden",
].join(";");
const heading = document.createElement("h2");
heading.textContent = title;
heading.style.cssText = [
"margin:0", "padding:18px 20px", "font-size:18px", "line-height:1.35",
`border-top:4px solid ${tone === "error" ? "#c83b3b" : "#16845b"}`,
"border-bottom:1px solid #e6e6e6",
].join(";");
const list = document.createElement("dl");
list.style.cssText = "margin:0;padding:8px 20px 4px";
for (const row of rows) {
const wrap = document.createElement("div");
wrap.style.cssText = "display:grid;grid-template-columns:120px minmax(0,1fr);gap:12px;padding:10px 0;border-bottom:1px solid #eee";
const label = document.createElement("dt");
label.textContent = row.label;
label.style.cssText = "color:#666;font-size:13px";
const value = document.createElement("dd");
value.textContent = row.value;
value.style.cssText = `margin:0;font-size:${row.primary ? "20px" : "14px"};font-weight:${row.primary ? "700" : "500"};overflow-wrap:anywhere`;
wrap.append(label, value);
list.append(wrap);
}
const footer = document.createElement("footer");
footer.style.cssText = "display:flex;justify-content:flex-end;padding:14px 20px";
const close = document.createElement("button");
close.type = "button";
close.textContent = "关闭";
close.style.cssText = "border:1px solid #bbb;border-radius:6px;background:#fff;color:#171717;padding:8px 18px;font:inherit;cursor:pointer";
close.addEventListener("click", () => dialog.close());
dialog.addEventListener("click", (event) => {
if (event.target === dialog) dialog.close();
});
dialog.addEventListener("close", () => dialog.remove(), {once: true});
footer.append(close);
panel.append(heading, list, footer);
dialog.append(panel);
document.body.append(dialog);
dialog.showModal();
close.focus();
}
try {
if (window.location.origin !== CHATGPT_ORIGIN) {
throw new Error("请在 https://chatgpt.com/ 页面运行此脚本");
}
const initialSession = await requestJson("/api/auth/session", {}, "读取登录会话");
const initialContext = authContext(initialSession);
if (!initialContext.accessToken) throw new Error("当前页面没有有效登录会话");
const accounts = await requestJson(
"/backend-api/accounts/check/v4-2023-04-27",
{headers: {Authorization: `Bearer ${initialContext.accessToken}`}},
"读取 Workspace 列表",
);
const workspace = chooseWorkspace(workspaceRows(accounts), initialContext.accountId);
const context = await workspaceSession(workspace.id, initialSession);
const result = await singleSeatPreview(context.accessToken, workspace.id);
const amount = previewAmount(result.preview);
const renewal = billingTime(result.preview?.renewal_date);
showDialog({
title: "单个席位费用",
rows: [
{label: "新增 1 席费用", value: amount.formatted, primary: true},
{label: "账单时间", value: `${renewal.formatted}(北京时间)`},
...(renewal.raw ? [{label: "原始账单时间", value: renewal.raw}] : []),
{label: "席位变化", value: `${result.currentSeats} → ${result.updatedSeats}`},
{label: "Workspace", value: `${workspace.name} (${workspace.id})`},
],
});
} catch (error) {
showDialog({
title: "席位费用检测失败",
tone: "error",
rows: [
{label: "错误", value: firstText(error?.message, error)},
],
});
console.error("[seat-cost-check]", error);
}
})();