mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-04 02:52:55 +08:00
433 lines
15 KiB
TypeScript
433 lines
15 KiB
TypeScript
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
|
import type {
|
|
ResourceDraft,
|
|
ResourceImportFile,
|
|
ResourceImportResult,
|
|
ResourceImportSupport,
|
|
ResourceMetric,
|
|
ResourcePatch,
|
|
ResourceSnapshot,
|
|
ResourceState,
|
|
ResourceView,
|
|
} from "cursor-byok:resource";
|
|
|
|
export const RESOURCE_TYPE = "chatgpt-account";
|
|
|
|
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
|
|
export type QuotaWindow = {
|
|
usedPercent: number | null;
|
|
remainingPercent: number | null;
|
|
resetAtMs: number | null;
|
|
};
|
|
|
|
export type AccountQuota = {
|
|
planLabel: string | null;
|
|
weekly: QuotaWindow | null;
|
|
fiveHour: QuotaWindow | null;
|
|
limitReached: boolean;
|
|
updatedAtMs: number;
|
|
};
|
|
|
|
/** 单条 chatgpt-account 资源的 privateData 形状。 */
|
|
export type AccountData = {
|
|
accessToken: string;
|
|
refreshToken: string | null;
|
|
displayName: string;
|
|
quota: AccountQuota | null;
|
|
};
|
|
|
|
export type CredentialCandidate = {
|
|
accessToken: string;
|
|
refreshToken: string | null;
|
|
displayName: string | null;
|
|
};
|
|
|
|
function object(value: unknown): Record<string, unknown> | null {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function text(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function number(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim()) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
|
const encoded = token.split(".")[1];
|
|
if (!encoded) return null;
|
|
try {
|
|
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
|
return object(JSON.parse(new TextDecoder().decode(bytes)));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function claim(payload: Record<string, unknown> | null, key: string): string | null {
|
|
return payload ? text(payload[key]) : null;
|
|
}
|
|
|
|
export function chatGptAccountId(accessToken: string): string | null {
|
|
const payload = decodeJwtPayload(accessToken);
|
|
const auth = object(payload?.["https://api.openai.com/auth"]);
|
|
return text(auth?.chatgpt_account_id) ?? claim(payload, "chatgpt_account_id");
|
|
}
|
|
|
|
async function tokenFingerprint(token: string): Promise<string> {
|
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
return Array.from(
|
|
new Uint8Array(digest).slice(0, 8),
|
|
(byte) => byte.toString(16).padStart(2, "0"),
|
|
).join("");
|
|
}
|
|
|
|
/** ChatGPT access token 的邮箱通常在 OpenAI 的 profile 声明里,而不是顶层 email。 */
|
|
function profileEmail(payload: Record<string, unknown> | null): string | null {
|
|
const profile = object(payload?.["https://api.openai.com/profile"]);
|
|
return text(profile?.email);
|
|
}
|
|
|
|
export async function accountIdentity(
|
|
accessToken: string,
|
|
): Promise<{ key: string; displayName: string }> {
|
|
const payload = decodeJwtPayload(accessToken);
|
|
const identity = chatGptAccountId(accessToken) ??
|
|
claim(payload, "sub") ??
|
|
claim(payload, "email") ??
|
|
await tokenFingerprint(accessToken);
|
|
const displayName = claim(payload, "email") ??
|
|
profileEmail(payload) ??
|
|
claim(payload, "preferred_username") ??
|
|
claim(payload, "name") ??
|
|
identity;
|
|
return { key: `codex:${identity}`, displayName };
|
|
}
|
|
|
|
export async function credentialDraft(credential: CredentialCandidate): Promise<ResourceDraft> {
|
|
const identity = await accountIdentity(credential.accessToken);
|
|
const data: AccountData = {
|
|
accessToken: credential.accessToken,
|
|
refreshToken: credential.refreshToken,
|
|
displayName: credential.displayName ?? identity.displayName,
|
|
quota: null,
|
|
};
|
|
return { key: identity.key, privateData: data as unknown as JsonValue };
|
|
}
|
|
|
|
export function accountData(resource: ResourceSnapshot): AccountData {
|
|
const data = object(resource.privateData);
|
|
const accessToken = text(data?.accessToken);
|
|
if (!accessToken) throw new Error("ChatGPT account resource is missing its access token");
|
|
return {
|
|
accessToken,
|
|
refreshToken: text(data?.refreshToken),
|
|
displayName: text(data?.displayName) ?? "ChatGPT account",
|
|
quota: (data?.quota ?? null) as AccountQuota | null,
|
|
};
|
|
}
|
|
|
|
export function accountHeaders(data: AccountData): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
accept: "application/json",
|
|
originator: "codex_cli_rs",
|
|
authorization: `Bearer ${data.accessToken}`,
|
|
};
|
|
const accountId = chatGptAccountId(data.accessToken);
|
|
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
|
return headers;
|
|
}
|
|
|
|
function clampPercent(value: number): number {
|
|
return Math.max(0, Math.min(100, value));
|
|
}
|
|
|
|
function resetAtMs(window: Record<string, unknown>, nowMs: number): number | null {
|
|
const resetAt = window.reset_at ?? window.resetAt;
|
|
const numeric = number(resetAt);
|
|
if (numeric !== null) return numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
|
if (typeof resetAt === "string") {
|
|
const parsed = Date.parse(resetAt);
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
const afterSeconds = number(window.reset_after_seconds ?? window.resetAfterSeconds);
|
|
return afterSeconds === null ? null : nowMs + afterSeconds * 1000;
|
|
}
|
|
|
|
function quotaWindow(value: unknown, nowMs: number): QuotaWindow | null {
|
|
const window = object(value);
|
|
if (!window) return null;
|
|
const used = number(window.used_percent ?? window.usedPercent);
|
|
const remaining = used === null
|
|
? number(window.remaining_percent ?? window.remainingPercent)
|
|
: clampPercent(100 - used);
|
|
return {
|
|
usedPercent: used === null
|
|
? (remaining === null ? null : clampPercent(100 - remaining))
|
|
: clampPercent(used),
|
|
remainingPercent: remaining === null ? null : clampPercent(remaining),
|
|
resetAtMs: resetAtMs(window, nowMs),
|
|
};
|
|
}
|
|
|
|
function planLabel(value: unknown): string | null {
|
|
const plan = text(value);
|
|
if (!plan) return null;
|
|
const labels: Record<string, string> = {
|
|
plus: "ChatGPT Plus",
|
|
pro: "ChatGPT Pro",
|
|
team: "ChatGPT Team",
|
|
business: "ChatGPT Business",
|
|
enterprise: "ChatGPT Enterprise",
|
|
free: "ChatGPT Free",
|
|
go: "ChatGPT Go",
|
|
};
|
|
return labels[plan.toLowerCase()] ?? plan;
|
|
}
|
|
|
|
export function parseCodexUsage(body: unknown, nowMs = Date.now()): AccountQuota {
|
|
const root = object(body) ?? {};
|
|
const rateLimit = object(root.rate_limit ?? root.rateLimit) ?? root;
|
|
const primary = rateLimit.primary_window ?? rateLimit.primaryWindow;
|
|
const secondary = rateLimit.secondary_window ?? rateLimit.secondaryWindow;
|
|
const weekly = quotaWindow(secondary ?? primary, nowMs);
|
|
const fiveHour = secondary === undefined || secondary === null
|
|
? null
|
|
: quotaWindow(primary, nowMs);
|
|
const explicitLimit = rateLimit.limit_reached ?? rateLimit.limitReached;
|
|
return {
|
|
planLabel: planLabel(root.plan_type ?? root.planType),
|
|
weekly,
|
|
fiveHour,
|
|
limitReached: typeof explicitLimit === "boolean" ? explicitLimit : [weekly, fiveHour].some(
|
|
(window) => window?.remainingPercent !== null && window?.remainingPercent === 0,
|
|
),
|
|
updatedAtMs: nowMs,
|
|
};
|
|
}
|
|
|
|
function windowCoolingUntil(window: QuotaWindow | null, nowMs: number): number | null {
|
|
if (!window || window.remainingPercent === null || window.remainingPercent > 0) return null;
|
|
if (window.resetAtMs !== null && window.resetAtMs <= nowMs) return null;
|
|
return window.resetAtMs ?? nowMs + FIVE_HOURS_MS;
|
|
}
|
|
|
|
export function quotaCoolingUntil(quota: AccountQuota, nowMs = Date.now()): number | null {
|
|
const resets = [
|
|
windowCoolingUntil(quota.weekly, nowMs),
|
|
windowCoolingUntil(quota.fiveHour, nowMs),
|
|
].filter((value): value is number => value !== null);
|
|
if (resets.length > 0) return Math.max(...resets);
|
|
return quota.limitReached ? nowMs + FIVE_HOURS_MS : null;
|
|
}
|
|
|
|
export function quotaState(quota: AccountQuota | null, nowMs = Date.now()): ResourceState {
|
|
if (!quota) return { status: "ready" };
|
|
const coolingUntil = quotaCoolingUntil(quota, nowMs);
|
|
return coolingUntil === null
|
|
? { status: "ready" }
|
|
: { status: "cooling", retryAtMs: coolingUntil, message: "ChatGPT quota is exhausted" };
|
|
}
|
|
|
|
/** 从上游错误文本中提取重置时间;拿不到时回退 5 小时。 */
|
|
function resetFromError(error: string, nowMs: number): number {
|
|
const resetAt = error.match(/["']?reset_at["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?)/i)?.[1];
|
|
if (resetAt) {
|
|
const value = Number(resetAt);
|
|
if (Number.isFinite(value)) return value > 10_000_000_000 ? value : value * 1000;
|
|
}
|
|
const resetAfter = error.match(/["']?reset_after_seconds["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?)/i)
|
|
?.[1];
|
|
if (resetAfter) {
|
|
const value = Number(resetAfter);
|
|
if (Number.isFinite(value)) return nowMs + value * 1000;
|
|
}
|
|
return nowMs + FIVE_HOURS_MS;
|
|
}
|
|
|
|
/** 额度耗尽时的资源补丁:标记 5 小时窗口耗尽并按重置时间进入冷却。 */
|
|
export function quotaExhaustedPatch(
|
|
data: AccountData,
|
|
error: string,
|
|
nowMs = Date.now(),
|
|
): ResourcePatch {
|
|
const quota: AccountQuota = {
|
|
planLabel: data.quota?.planLabel ?? null,
|
|
weekly: data.quota?.weekly ?? null,
|
|
fiveHour: {
|
|
usedPercent: 100,
|
|
remainingPercent: 0,
|
|
resetAtMs: resetFromError(error, nowMs),
|
|
},
|
|
limitReached: true,
|
|
updatedAtMs: nowMs,
|
|
};
|
|
return {
|
|
privateData: { ...data, quota } as unknown as JsonValue,
|
|
state: quotaState(quota, nowMs),
|
|
};
|
|
}
|
|
|
|
export function presentAccount(resource: ResourceSnapshot): ResourceView {
|
|
const data = accountData(resource);
|
|
const metrics: ResourceMetric[] = [];
|
|
const weekly = data.quota?.weekly;
|
|
if (weekly && weekly.remainingPercent !== null) {
|
|
metrics.push({
|
|
id: "weekly",
|
|
label: { "en-US": "Weekly quota", "zh-CN": "周额度" },
|
|
unit: "percent",
|
|
value: weekly.remainingPercent,
|
|
...(weekly.resetAtMs !== null ? { resetAtMs: weekly.resetAtMs } : {}),
|
|
});
|
|
}
|
|
const fiveHour = data.quota?.fiveHour;
|
|
if (fiveHour && fiveHour.remainingPercent !== null) {
|
|
metrics.push({
|
|
id: "five-hour",
|
|
label: { "en-US": "5-hour window", "zh-CN": "5 小时窗口" },
|
|
unit: "percent",
|
|
value: fiveHour.remainingPercent,
|
|
...(fiveHour.resetAtMs !== null ? { resetAtMs: fiveHour.resetAtMs } : {}),
|
|
});
|
|
}
|
|
return {
|
|
// 旧记录可能存的是账号 ID;展示时优先从 token 现算邮箱。
|
|
displayName: jwtDisplayName(data.accessToken) ?? data.displayName,
|
|
...(data.quota?.planLabel ? { description: data.quota.planLabel } : {}),
|
|
...(metrics.length > 0 ? { metrics } : {}),
|
|
};
|
|
}
|
|
|
|
export async function refreshAccount(
|
|
resource: ResourceSnapshot,
|
|
context: PluginContext,
|
|
): Promise<ResourcePatch> {
|
|
const data = accountData(resource);
|
|
const response = await context.network.fetch(USAGE_URL, {
|
|
method: "GET",
|
|
headers: accountHeaders(data),
|
|
});
|
|
if (response.status < 200 || response.status >= 300) {
|
|
if (response.status === 401) {
|
|
return {
|
|
state: { status: "invalid", message: "ChatGPT authorization expired; sign in again" },
|
|
};
|
|
}
|
|
throw new Error(`Codex usage lookup failed (HTTP ${response.status}): ${response.body}`);
|
|
}
|
|
let body: unknown;
|
|
try {
|
|
body = JSON.parse(response.body);
|
|
} catch {
|
|
throw new Error("Codex usage lookup returned invalid JSON");
|
|
}
|
|
const quota = parseCodexUsage(body);
|
|
return {
|
|
privateData: { ...data, quota } as unknown as JsonValue,
|
|
state: quotaState(quota),
|
|
};
|
|
}
|
|
|
|
function firstText(source: Record<string, unknown>, keys: string[]): string | null {
|
|
for (const key of keys) {
|
|
const value = text(source[key]);
|
|
if (value) return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function jwtDisplayName(token: string | null): string | null {
|
|
if (!token) return null;
|
|
const payload = decodeJwtPayload(token);
|
|
return claim(payload, "email") ?? profileEmail(payload) ??
|
|
claim(payload, "preferred_username") ?? claim(payload, "name");
|
|
}
|
|
|
|
function collectCredentials(value: unknown, output: CredentialCandidate[]): void {
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) collectCredentials(item, output);
|
|
return;
|
|
}
|
|
const item = object(value);
|
|
if (!item || item.disabled === true) return;
|
|
for (const key of ["accounts", "credentials", "items"]) {
|
|
if (Array.isArray(item[key])) {
|
|
collectCredentials(item[key], output);
|
|
return;
|
|
}
|
|
}
|
|
const tokens = object(item.tokens) ?? item;
|
|
const accessToken = firstText(tokens, ["access_token", "accessToken", "token", "key"]) ??
|
|
firstText(item, ["access_token", "accessToken", "token", "key", "OPENAI_API_KEY"]);
|
|
if (!accessToken) return;
|
|
const refreshToken = firstText(tokens, ["refresh_token", "refreshToken"]) ??
|
|
firstText(item, ["refresh_token", "refreshToken"]);
|
|
const idToken = firstText(tokens, ["id_token", "idToken"]) ??
|
|
firstText(item, ["id_token", "idToken"]);
|
|
const displayName = firstText(item, ["email", "display_name", "displayName", "name"]) ??
|
|
firstText(tokens, ["email", "display_name", "displayName", "name"]) ??
|
|
jwtDisplayName(idToken);
|
|
output.push({ accessToken, refreshToken, displayName });
|
|
}
|
|
|
|
export function parseCredentialFiles(files: ResourceImportFile[]): {
|
|
credentials: CredentialCandidate[];
|
|
warnings: string[];
|
|
} {
|
|
const credentials: CredentialCandidate[] = [];
|
|
const warnings: string[] = [];
|
|
for (const file of files) {
|
|
let content: unknown;
|
|
try {
|
|
content = JSON.parse(file.content);
|
|
} catch {
|
|
warnings.push(`${file.name}: not valid JSON`);
|
|
continue;
|
|
}
|
|
const found: CredentialCandidate[] = [];
|
|
collectCredentials(content, found);
|
|
if (found.length === 0) {
|
|
warnings.push(`${file.name}: no ChatGPT access token found`);
|
|
continue;
|
|
}
|
|
credentials.push(...found);
|
|
}
|
|
return { credentials, warnings };
|
|
}
|
|
|
|
export const credentialImport: ResourceImportSupport = {
|
|
displayName: {
|
|
"en-US": "Import Codex credentials",
|
|
"zh-CN": "导入 Codex 凭证",
|
|
},
|
|
description: {
|
|
"en-US": "Import one or more Codex JSON credential files.",
|
|
"zh-CN": "导入一个或多个 Codex JSON 凭证文件。",
|
|
},
|
|
accept: [".json"],
|
|
multiple: true,
|
|
parse: async (files: ResourceImportFile[]): Promise<ResourceImportResult> => {
|
|
const { credentials, warnings } = parseCredentialFiles(files);
|
|
if (credentials.length === 0) {
|
|
throw new Error(warnings.join("; ") || "credential JSON does not contain an access token");
|
|
}
|
|
return {
|
|
resources: await Promise.all(credentials.map(credentialDraft)),
|
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
};
|
|
},
|
|
};
|