From ab12b4f5cd13067596059e9dd2d84d5f207d4e1c Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Mon, 18 May 2026 08:49:34 -0700 Subject: [PATCH] feat: add Cockpit account transfer compatibility --- CHANGELOG.md | 1 + README.md | 18 +- shared/account-transfer-client.test.ts | 61 ++++ shared/account-transfer-client.ts | 67 ++++ shared/hooks/use-accounts.ts | 45 +-- shared/i18n/translations.ts | 10 + shared/types.ts | 2 + src/auth/quota-utils.ts | 26 +- src/auth/types.ts | 5 +- src/proxy/rate-limit-headers.ts | 7 + src/routes/accounts.ts | 49 +-- src/services/account-transfer-formats.ts | 318 ++++++++++++++++++ tests/unit/auth/quota-utils.test.ts | 7 + .../routes/accounts-import-export.test.ts | 107 ++++++ .../routes/shared/proxy-rate-limit.test.ts | 2 + .../services/account-transfer-formats.test.ts | 141 ++++++++ .../components/AccountImportExport.test.tsx | 45 +++ web/src/components/AccountImportExport.tsx | 35 +- web/src/components/AccountList.tsx | 3 +- 19 files changed, 867 insertions(+), 82 deletions(-) create mode 100644 shared/account-transfer-client.test.ts create mode 100644 shared/account-transfer-client.ts create mode 100644 src/services/account-transfer-formats.ts create mode 100644 tests/unit/services/account-transfer-formats.test.ts create mode 100644 web/src/components/AccountImportExport.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b893d2..48b1374a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Added +- Account transfer compatibility with Cockpit Tools / Sub2API / CPA: normalized quota windows now include `remaining_percent` while preserving `used_percent`; successful `GET /auth/accounts/:id/quota` calls update cached quota; account import accepts Cockpit Tools portable objects, direct `accessToken` / `refresh_token`, Sub2API exports, raw arrays, and `text/plain` token lines; account export adds `format=cockpit_tools|sub2api|cpa` alongside existing `full|minimal`; Dashboard import/export now sends JSON or token-line files without forcing `{ accounts: [...] }` and exposes the export format selector. Docs now record the `/backend-api/wham/usage` active quota path and compatibility formats (`src/auth/quota-utils.ts`, `src/proxy/rate-limit-headers.ts`, `src/routes/accounts.ts`, `src/services/account-transfer-formats.ts`, `shared/account-transfer-client.ts`, `shared/hooks/use-accounts.ts`, `web/src/components/AccountImportExport.tsx`, `docs/architecture/auth/quota.md`, `docs/architecture/auth/import-export.md`, `docs/todo/cockpit-tools-quota-import-export.md`). - 支持 Dashboard 配置模型映射与本地自定义模型目录:`data/local.yaml` 可把客户端模型名映射到 Codex 模型、带 provider 前缀的第三方模型或已有 `model_routing` 目标;Dashboard → Settings → 模型映射可直接增删 alias 并热加载后端;`model.custom_models` 可把自定义 Codex-compatible ID 加入 `/v1/models/catalog` 并支持 `-fast` / `-high` 等后缀。ModelStore 会用本地 alias 覆盖静态 `config/models.yaml` alias,UpstreamRouter 会在内置 Claude/Gemini 自动路由前解析 alias,并在直连 provider 请求中把 outgoing `model` 改写为映射目标。新增 schema / model-store / upstream-router / route direct guard / Dashboard 组件测试覆盖配置默认值、静态 alias 覆盖、custom catalog、provider target 路由、四类直连接口(Chat / Messages / Responses / Gemini)的目标模型透传和 UI 持久化(`src/config-schema.ts`、`src/models/model-store.ts`、`src/proxy/upstream-router.ts`、`src/routes/admin/settings.ts`、`src/routes/chat.ts`、`src/routes/messages.ts`、`src/routes/responses.ts`、`src/routes/gemini.ts`、`web/src/components/ModelAliasSettings.tsx`、`tests/unit/config-schema.test.ts`、`tests/unit/models/model-store.test.ts`、`tests/unit/proxy/upstream-router.test.ts`、`tests/unit/routes/general-settings.test.ts`、`tests/unit/routes/upstream-auth-bypass.test.ts`、`web/src/components/ModelAliasSettings.test.tsx`) - Stream-close 事件结构化落盘到 Errors tab + 审计 log:`premature stream close` / `stream-client-abort` / `stream-client-disconnect` / `stream-error` 此前只走 `console.warn` 进 `dev-YYYY-MM-DD.log`,需要 grep 才能定位,且生产模式没有 tee;新增 `src/logs/stream-close-event.ts` 把这些事件同时写到 `data/error-log.jsonl`(Errors tab 按签名分组 + 角标计数)和 `logStore`(`/admin/logs` 审计流)。覆盖 7 个调用点:`proxy-handler.ts` 两处 client abort + 一处 `UpstreamPrematureCloseError`(带 eventCount / hadReasoning / responseId / variantHash)、`response-processor.ts` 两处(`client-write-failed` 带 writtenChunks/Bytes/lastSentEvent;`upstream-error` 带 upstreamStatus)、`responses.ts` 两处 `streamPassthrough` 内部 EOF(rid / accountEntryId / variantHash 通过 `FormatAdapter.streamTranslator` 的 `streamContext` option 由 `response-processor` 透传,其它 adapter 兼容性接收并忽略)。顺手修 `error-log.ts:readAppVersion` 在 config 未加载时崩溃(unit-test 路径会撞到),改为 try/catch 兜底回退 "unknown"。新增 `tests/unit/logs/stream-close-event.test.ts` 6 个单测覆盖 4 种 kind + 缺失 rid 兜底 + numeric upstreamStatus → audit status 透传 + direct upstream provider/path;Errors tab 展开分组时会显示 sample context。下次复现 premature close 直接看 Errors tab 按 `StreamUpstreamPrematureClose` 分组拉 rid + account + closeCode,不用再 grep dev 日志(`src/logs/stream-close-event.ts`、`src/logs/error-log.ts`、`src/routes/shared/proxy-handler.ts`、`src/routes/shared/response-processor.ts`、`src/routes/responses.ts`、`tests/unit/logs/stream-close-event.test.ts`) - Opt-in 上游请求/响应 dumper:新增 `src/utils/debug-dump.ts`,环境变量 `CODEX_PROXY_DEBUG_DUMP=1` 启用时把每次上游请求 + 流式 chunk + 终止状态 + 错误写入 `/tmp/codex-proxy-dump-.jsonl`(一行一事件);未启用时所有 hook 是 `if (debugDumpEnabled())` 守护下的纯 boolean check,零开销。在 `src/routes/shared/proxy-handler.ts` 加 1 个 hook(`request`,含 rid/tag/entryId/conv/implicitResumeActive/resumeReason/payload),在 `src/routes/shared/response-processor.ts` 加 3 个 hook(`upstream-chunk` 截断到 16KB、`stream-finish` 含 chunks/bytes/sawTerminal、`stream-error` 含 status/msg/body 截断到 4KB)。**privacy 警告**:dump 文件包含完整 request payload(含用户 prompt)和上游响应,路径在启动时打印一次提示 sensitive 性质。日常排查"账号轮换重试风暴" / "premature stream close" 等偶发错误时 opt-in 启用,问题复现后再 opt-out diff --git a/README.md b/README.md index c859db62..f68ee038 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ curl http://localhost:8080/v1/chat/completions \ - **多账号轮换** — `least_used`(最少使用优先)、`round_robin`(轮询)、`sticky`(粘性)三种策略 - **Plan Routing** — 不同 plan(free/plus/team/business)的账号自动路由到各自支持的模型 - **Token 自动续期** — JWT 到期前自动刷新,指数退避重试 -- **配额被动采集** — 从上游响应头和 WebSocket rate limit 事件更新账号额度;`quota.refresh_interval_minutes` 仅控制用量快照记录,`0` 表示关闭快照定时器。 +- **配额采集** — 默认从上游响应头和 WebSocket rate limit 事件被动更新账号额度;用户手动查询单账号额度时会调用 `/backend-api/wham/usage`,并把 `remaining_percent = 100 - used_percent` 写入缓存。 - **封禁检测** — 上游 403 自动标记 banned;401 token 吊销自动过期并切换账号 - **API Key Provider 池** — 支持通过 Dashboard 管理第三方 API Key、模型列表、导入导出和启停状态。 - **Web 控制面板** — 账号管理、用量统计、批量操作,中英双语;远程访问需 Dashboard 登录门 @@ -745,10 +745,10 @@ curl -N http://localhost:8080/official-agent/threads/{threadId}/turns \ | 端点 | 方法 | 说明 | |------|------|------| | `/auth/login` | GET | OAuth 登录入口 | -| `/auth/accounts` | GET | 账号列表(`?quota=true` / `?quota=fresh`) | +| `/auth/accounts` | GET | 账号列表(含缓存额度) | | `/auth/accounts` | POST | 添加单个账号(token 或 refreshToken) | -| `/auth/accounts/import` | POST | 批量导入账号 | -| `/auth/accounts/export` | GET | 导出账号(`?format=minimal` 精简格式) | +| `/auth/accounts/import` | POST | 批量导入账号(JSON / `text/plain` token 行) | +| `/auth/accounts/export` | GET | 导出账号(`?format=full|minimal|cockpit_tools|sub2api|cpa`) | | `/auth/accounts/batch-delete` | POST | 批量删除账号 | | `/auth/accounts/batch-status` | POST | 批量修改账号状态 | | `/auth/accounts/health-check` | POST | 批量检测账号可用性 | @@ -782,6 +782,10 @@ curl -s http://localhost:8080/auth/accounts/export \ curl -s "http://localhost:8080/auth/accounts/export?format=minimal" \ -H "Authorization: Bearer your-api-key" > backup-minimal.json +# 导出第三方兼容格式 +curl -s "http://localhost:8080/auth/accounts/export?format=sub2api" \ + -H "Authorization: Bearer your-api-key" > sub2api-accounts.json + # 批量导入(支持 token、refreshToken,或两者同时传) curl -X POST http://localhost:8080/auth/accounts/import \ -H "Content-Type: application/json" \ @@ -795,6 +799,12 @@ curl -X POST http://localhost:8080/auth/accounts/import \ }' # 返回: { "added": 2, "updated": 1, "failed": 0, "errors": [] } +# text/plain token 行导入(每行 access token 或 refresh token) +curl -X POST http://localhost:8080/auth/accounts/import \ + -H "Content-Type: text/plain" \ + -H "Authorization: Bearer your-api-key" \ + --data-binary $'eyJhbGciOi...\noaistb_rt_...\n' + # 备份恢复一键操作(导出后直接导入到另一个实例) curl -X POST http://localhost:8080/auth/accounts/import \ -H "Content-Type: application/json" \ diff --git a/shared/account-transfer-client.test.ts b/shared/account-transfer-client.test.ts new file mode 100644 index 00000000..8a2004d7 --- /dev/null +++ b/shared/account-transfer-client.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + accountExportDownloadName, + buildAccountExportUrl, + prepareAccountImportRequest, +} from "./account-transfer-client"; + +function makeFile(name: string, text: string, type = "") { + return { + name, + type, + text: async () => text, + }; +} + +describe("account transfer browser client helpers", () => { + it("builds export URLs and download names for compatibility formats", () => { + expect(buildAccountExportUrl(["acct-1", "acct-2"], "sub2api")) + .toBe("/auth/accounts/export?ids=acct-1%2Cacct-2&format=sub2api"); + expect(buildAccountExportUrl(undefined, "full")).toBe("/auth/accounts/export"); + expect(accountExportDownloadName("cockpit_tools", "2026-05-18")) + .toBe("accounts-export-cockpit-tools-2026-05-18.json"); + }); + + it("keeps JSON import payloads intact instead of forcing accounts arrays", async () => { + const request = await prepareAccountImportRequest(makeFile( + "sub2api.json", + JSON.stringify({ type: "sub2api-data", accounts: [{ credentials: { access_token: "token" } }] }), + "application/json", + )); + + expect(request).toEqual({ + ok: true, + contentType: "application/json", + body: JSON.stringify({ type: "sub2api-data", accounts: [{ credentials: { access_token: "token" } }] }), + }); + }); + + it("prepares text/plain token line imports", async () => { + const request = await prepareAccountImportRequest(makeFile( + "tokens.txt", + "plain.access.token\nrt_text_only\n", + "text/plain", + )); + + expect(request).toEqual({ + ok: true, + contentType: "text/plain", + body: "plain.access.token\nrt_text_only\n", + }); + }); + + it("rejects malformed .json files before sending them", async () => { + const request = await prepareAccountImportRequest(makeFile("broken.json", "{not json", "application/json")); + + expect(request).toEqual({ + ok: false, + error: "Invalid JSON file", + }); + }); +}); diff --git a/shared/account-transfer-client.ts b/shared/account-transfer-client.ts new file mode 100644 index 00000000..e45d348d --- /dev/null +++ b/shared/account-transfer-client.ts @@ -0,0 +1,67 @@ +export type AccountExportFormat = "full" | "minimal" | "cockpit_tools" | "sub2api" | "cpa"; + +export const ACCOUNT_EXPORT_FORMATS: AccountExportFormat[] = [ + "full", + "minimal", + "cockpit_tools", + "sub2api", + "cpa", +]; + +export interface AccountImportFile { + name: string; + type?: string; + text(): Promise; +} + +export type PreparedAccountImportRequest = + | { ok: true; contentType: "application/json" | "text/plain"; body: string } + | { ok: false; error: string }; + +function isLikelyJsonFile(file: AccountImportFile): boolean { + const name = file.name.toLowerCase(); + const type = file.type?.toLowerCase() ?? ""; + return name.endsWith(".json") || type.includes("json"); +} + +function isJsonText(text: string): boolean { + const trimmed = text.trimStart(); + return trimmed.startsWith("{") || trimmed.startsWith("["); +} + +export function buildAccountExportUrl( + selectedIds: string[] | undefined, + format: AccountExportFormat = "full", +): string { + const params = new URLSearchParams(); + if (selectedIds && selectedIds.length > 0) params.set("ids", selectedIds.join(",")); + if (format !== "full") params.set("format", format); + const qs = params.toString(); + return `/auth/accounts/export${qs ? `?${qs}` : ""}`; +} + +export function accountExportDownloadName( + format: AccountExportFormat, + date = new Date().toISOString().slice(0, 10), +): string { + const suffix = format === "full" ? "" : `-${format.replaceAll("_", "-")}`; + return `accounts-export${suffix}-${date}.json`; +} + +export async function prepareAccountImportRequest( + file: AccountImportFile, +): Promise { + const body = await file.text(); + if (!body.trim()) return { ok: false, error: "No importable content" }; + + if (isLikelyJsonFile(file) || isJsonText(body)) { + try { + JSON.parse(body) as unknown; + return { ok: true, contentType: "application/json", body }; + } catch { + if (isLikelyJsonFile(file)) return { ok: false, error: "Invalid JSON file" }; + } + } + + return { ok: true, contentType: "text/plain", body }; +} diff --git a/shared/hooks/use-accounts.ts b/shared/hooks/use-accounts.ts index 74db5bb1..95df676a 100644 --- a/shared/hooks/use-accounts.ts +++ b/shared/hooks/use-accounts.ts @@ -1,5 +1,11 @@ import { useState, useEffect, useCallback, useRef } from "preact/hooks"; import type { Account } from "../types"; +import { + accountExportDownloadName, + buildAccountExportUrl, + prepareAccountImportRequest, + type AccountExportFormat, +} from "../account-transfer-client"; export interface PersistenceHealth { ok: boolean; @@ -207,20 +213,14 @@ export function useAccounts() { setList((prev) => prev.map((a) => a.id === accountId ? { ...a, ...patch } : a)); }, []); - const exportAccounts = useCallback(async (selectedIds?: string[], format?: "full" | "minimal") => { - const params = new URLSearchParams(); - if (selectedIds && selectedIds.length > 0) params.set("ids", selectedIds.join(",")); - if (format === "minimal") params.set("format", "minimal"); - const qs = params.toString() ? `?${params.toString()}` : ""; - const resp = await fetch(`/auth/accounts/export${qs}`); - const data = await resp.json() as { accounts: Array<{ id: string }> }; + const exportAccounts = useCallback(async (selectedIds?: string[], format: AccountExportFormat = "full") => { + const resp = await fetch(buildAccountExportUrl(selectedIds, format)); + const data = await resp.json() as unknown; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - const date = new Date().toISOString().slice(0, 10); - const suffix = format === "minimal" ? "-minimal" : ""; - a.download = `accounts-export${suffix}-${date}.json`; + a.download = accountExportDownloadName(format); a.style.display = "none"; document.body.appendChild(a); a.click(); @@ -235,30 +235,13 @@ export function useAccounts() { failed: number; errors: string[]; }> => { - const text = await file.text(); - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return { success: false, added: 0, updated: 0, failed: 0, errors: ["Invalid JSON file"] }; - } - // Support both { accounts: [...] } (export format) and raw array - const parsedObject = parsed && typeof parsed === "object" - ? parsed as { accounts?: unknown } - : null; - const accounts = Array.isArray(parsed) - ? parsed - : Array.isArray(parsedObject?.accounts) - ? parsedObject.accounts - : null; - if (!accounts) { - return { success: false, added: 0, updated: 0, failed: 0, errors: ["Invalid format: expected { accounts: [...] }"] }; - } + const prepared = await prepareAccountImportRequest(file); + if (!prepared.ok) return { success: false, added: 0, updated: 0, failed: 0, errors: [prepared.error] }; const resp = await fetch("/auth/accounts/import", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ accounts }), + headers: { "Content-Type": prepared.contentType }, + body: prepared.body, }); const result = await resp.json(); if (resp.ok) { diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index 784759b0..76f310df 100644 --- a/shared/i18n/translations.ts +++ b/shared/i18n/translations.ts @@ -367,7 +367,12 @@ export const translations = { editLabel: "Edit label", labelPlaceholder: "e.g. Team Alpha, Personal", downloadTemplate: "Download Template", + exportFormat: "Export format", + exportFull: "Full", exportMinimal: "Export (RT only)", + exportCockpitTools: "Cockpit Tools", + exportSub2Api: "Sub2API", + exportCpa: "CPA", pasteRefreshToken: "Paste refresh token", addByRt: "Add by RT", addingByRt: "Exchanging...", @@ -771,7 +776,12 @@ export const translations = { editLabel: "编辑标签", labelPlaceholder: "如 Team Alpha、个人", downloadTemplate: "下载模板", + exportFormat: "导出格式", + exportFull: "完整", exportMinimal: "导出(仅 RT)", + exportCockpitTools: "Cockpit Tools", + exportSub2Api: "Sub2API", + exportCpa: "CPA", pasteRefreshToken: "粘贴 Refresh Token", addByRt: "添加", addingByRt: "刷新中...", diff --git a/shared/types.ts b/shared/types.ts index ac517bf8..11c250c4 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,11 +1,13 @@ export interface AccountQuotaWindow { used_percent?: number | null; + remaining_percent?: number | null; limit_reached?: boolean; reset_at?: number | null; limit_window_seconds?: number | null; } export interface AccountQuota { + plan_type?: string; rate_limit?: AccountQuotaWindow; secondary_rate_limit?: AccountQuotaWindow | null; code_review_rate_limit?: (AccountQuotaWindow & { allowed?: boolean }) | null; diff --git a/src/auth/quota-utils.ts b/src/auth/quota-utils.ts index 47496f6b..1d7b5006 100644 --- a/src/auth/quota-utils.ts +++ b/src/auth/quota-utils.ts @@ -6,6 +6,11 @@ import type { CodexQuota } from "./types.js"; import type { CodexUsageRateLimit, CodexUsageResponse } from "../proxy/codex-api.js"; +function remainingPercent(used: number | null | undefined): number | null { + if (typeof used !== "number" || !Number.isFinite(used)) return null; + return Math.max(0, Math.min(100, Math.round(100 - Math.max(0, Math.min(100, used))))); +} + function isReviewLimitId(value: string | null | undefined): boolean { const normalized = (value ?? "").trim().toLowerCase().replace(/[-\s]+/g, "_"); return normalized === "review" || @@ -18,10 +23,12 @@ function isReviewLimitId(value: string | null | undefined): boolean { function quotaFromRateLimit(rateLimit: CodexUsageRateLimit | null | undefined) { if (!rateLimit) return null; + const usedPercent = rateLimit.primary_window?.used_percent ?? null; return { allowed: rateLimit.allowed, limit_reached: rateLimit.limit_reached, - used_percent: rateLimit.primary_window?.used_percent ?? null, + used_percent: usedPercent, + remaining_percent: remainingPercent(usedPercent), reset_at: rateLimit.primary_window?.reset_at ?? null, limit_window_seconds: rateLimit.primary_window?.limit_window_seconds ?? null, }; @@ -30,9 +37,11 @@ function quotaFromRateLimit(rateLimit: CodexUsageRateLimit | null | undefined) { function secondaryQuotaFromRateLimit(rateLimit: CodexUsageRateLimit | null | undefined) { const secondary = rateLimit?.secondary_window; if (!secondary) return null; + const usedPercent = secondary.used_percent ?? null; return { limit_reached: secondary.used_percent != null ? secondary.used_percent >= 100 : Boolean(rateLimit?.limit_reached), - used_percent: secondary.used_percent ?? null, + used_percent: usedPercent, + remaining_percent: remainingPercent(usedPercent), reset_at: secondary.reset_at ?? null, limit_window_seconds: secondary.limit_window_seconds ?? null, }; @@ -40,6 +49,7 @@ function secondaryQuotaFromRateLimit(rateLimit: CodexUsageRateLimit | null | und export function toQuota(usage: CodexUsageResponse): CodexQuota { const sw = usage.rate_limit.secondary_window; + const primaryUsedPercent = usage.rate_limit.primary_window?.used_percent ?? null; const additional = usage.additional_rate_limits ?? []; const rateLimitsByLimitId: NonNullable = {}; for (const item of additional) { @@ -66,18 +76,12 @@ export function toQuota(usage: CodexUsageResponse): CodexQuota { rate_limit: { allowed: usage.rate_limit.allowed, limit_reached: usage.rate_limit.limit_reached, - used_percent: usage.rate_limit.primary_window?.used_percent ?? null, + used_percent: primaryUsedPercent, + remaining_percent: remainingPercent(primaryUsedPercent), reset_at: usage.rate_limit.primary_window?.reset_at ?? null, limit_window_seconds: usage.rate_limit.primary_window?.limit_window_seconds ?? null, }, - secondary_rate_limit: sw - ? { - limit_reached: sw.used_percent != null ? sw.used_percent >= 100 : usage.rate_limit.limit_reached, - used_percent: sw.used_percent ?? null, - reset_at: sw.reset_at ?? null, - limit_window_seconds: sw.limit_window_seconds ?? null, - } - : null, + secondary_rate_limit: secondaryQuotaFromRateLimit(usage.rate_limit), code_review_rate_limit: codeReviewRateLimit, rate_limits_by_limit_id: Object.keys(rateLimitsByLimitId).length > 0 ? rateLimitsByLimitId diff --git a/src/auth/types.ts b/src/auth/types.ts index 41b3b759..b20f444e 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -94,11 +94,12 @@ export interface AccountInfo { /** A single rate limit window (primary or secondary). */ export interface CodexQuotaWindow { used_percent: number | null; + remaining_percent?: number | null; reset_at: number | null; limit_window_seconds: number | null; } -/** Official Codex quota from /backend-api/codex/usage */ +/** Official Codex quota from /backend-api/wham/usage or /backend-api/codex/usage. */ export interface CodexQuota { plan_type: string; rate_limit: CodexQuotaWindow & { @@ -113,6 +114,7 @@ export interface CodexQuota { allowed: boolean; limit_reached: boolean; used_percent: number | null; + remaining_percent?: number | null; reset_at: number | null; limit_window_seconds: number | null; } | null; @@ -123,6 +125,7 @@ export interface CodexQuota { allowed: boolean; limit_reached: boolean; used_percent: number | null; + remaining_percent?: number | null; reset_at: number | null; limit_window_seconds: number | null; secondary_rate_limit?: CodexQuotaWindow & { diff --git a/src/proxy/rate-limit-headers.ts b/src/proxy/rate-limit-headers.ts index 70825e4b..e64c12a4 100644 --- a/src/proxy/rate-limit-headers.ts +++ b/src/proxy/rate-limit-headers.ts @@ -78,11 +78,16 @@ export function rateLimitToQuota( ): CodexQuota { const primary = rl.primary; const secondary = rl.secondary; + const remainingPercent = (used: number | null | undefined): number | null => + typeof used === "number" && Number.isFinite(used) + ? Math.max(0, Math.min(100, Math.round(100 - Math.max(0, Math.min(100, used))))) + : null; return { plan_type: planType ?? "unknown", rate_limit: { used_percent: primary?.used_percent ?? null, + remaining_percent: remainingPercent(primary?.used_percent), reset_at: primary?.reset_at ?? null, limit_window_seconds: primary?.window_minutes != null ? primary.window_minutes * 60 : null, allowed: true, @@ -91,6 +96,7 @@ export function rateLimitToQuota( secondary_rate_limit: secondary ? { used_percent: secondary.used_percent, + remaining_percent: remainingPercent(secondary.used_percent), reset_at: secondary.reset_at, limit_window_seconds: secondary.window_minutes != null ? secondary.window_minutes * 60 : null, limit_reached: secondary.used_percent >= 100, @@ -103,6 +109,7 @@ export function rateLimitToQuota( rl.code_review.limit_reached ?? (rl.code_review.primary?.used_percent ?? 0) >= 100, used_percent: rl.code_review.primary?.used_percent ?? null, + remaining_percent: remainingPercent(rl.code_review.primary?.used_percent), reset_at: rl.code_review.primary?.reset_at ?? null, limit_window_seconds: rl.code_review.primary?.window_minutes != null diff --git a/src/routes/accounts.ts b/src/routes/accounts.ts index 3326e269..7f7fbadd 100644 --- a/src/routes/accounts.ts +++ b/src/routes/accounts.ts @@ -17,8 +17,15 @@ import { isBanError, isTokenInvalidError } from "../proxy/error-classification.j import { clearWarnings, getActiveWarnings, getWarningsLastUpdated } from "../auth/quota-warnings.js"; import { probeAccount, batchHealthCheck } from "../auth/health-check.js"; import { AccountImportService } from "../services/account-import.js"; +import type { ImportEntry } from "../services/account-import.js"; import { AccountQueryService } from "../services/account-query.js"; import { AccountMutationService } from "../services/account-mutation.js"; +import { + buildAccountExportPayload, + parseAccountExportFormat, + parseAccountImportPayload, + parseAccountImportText, +} from "../services/account-transfer-formats.js"; const BatchIdsSchema = z.object({ ids: z.array(z.string()).min(1) }); const HealthCheckSchema = z.object({ @@ -28,17 +35,6 @@ const HealthCheckSchema = z.object({ }).optional(); const BatchStatusSchema = z.object({ ids: z.array(z.string()).min(1), status: z.enum(["active", "disabled"]) }); const LabelSchema = z.object({ label: z.string().max(64).nullable() }); -const emptyStringToUndefined = (v: unknown) => - typeof v === "string" && v.trim() === "" ? undefined : v; -const emptyStringToNull = (v: unknown) => - typeof v === "string" && v.trim() === "" ? null : v; -const BulkImportEntrySchema = z.object({ - token: z.preprocess(emptyStringToUndefined, z.string().min(1).optional()), - refreshToken: z.preprocess(emptyStringToNull, z.string().min(1).nullable().optional()), - label: z.string().max(64).nullable().optional(), -}).refine((d) => Boolean(d.token) || Boolean(d.refreshToken), { message: "Either token or refreshToken is required" }); -const BulkImportSchema = z.object({ accounts: z.array(BulkImportEntrySchema).min(1) }); - export function createAccountRoutes(pool: AccountPool, scheduler: RefreshScheduler, cookieJar?: CookieJar, proxyPool?: ProxyPool): Hono { const app = new Hono(); const importSvc = new AccountImportService(pool, scheduler, { @@ -83,16 +79,29 @@ export function createAccountRoutes(pool: AccountPool, scheduler: RefreshSchedul app.get("/auth/accounts/export", (c) => { const ids = c.req.query("ids")?.split(",").filter(Boolean); - if (c.req.query("format") === "minimal") return c.json({ accounts: querySvc.exportMinimal(ids) }); - return c.json({ accounts: querySvc.exportFull(ids) }); + const format = parseAccountExportFormat(c.req.query("format")); + if (!format) { + c.status(400); + return c.json({ error: "Unsupported export format" }); + } + return c.json(buildAccountExportPayload(querySvc.exportFull(ids), format)); }); app.post("/auth/accounts/import", async (c) => { - let body: unknown; - try { body = await c.req.json(); } catch { c.status(400); return c.json({ error: "Malformed JSON request body" }); } - const parsed = BulkImportSchema.safeParse(body); - if (!parsed.success) { c.status(400); return c.json({ error: "Invalid request", details: parsed.error.issues }); } - return c.json({ success: true, ...(await importSvc.importMany(parsed.data.accounts)) }); + const contentType = c.req.header("content-type") ?? ""; + let entries: ImportEntry[]; + if (contentType.includes("text/plain")) { + entries = parseAccountImportText(await c.req.text()); + } else { + let body: unknown; + try { body = await c.req.json(); } catch { c.status(400); return c.json({ error: "Malformed JSON request body" }); } + entries = parseAccountImportPayload(body); + } + if (entries.length === 0) { + c.status(400); + return c.json({ error: "Invalid request", details: [{ message: "No importable accounts found" }] }); + } + return c.json({ success: true, ...(await importSvc.importMany(entries)) }); }); app.post("/auth/accounts/batch-delete", async (c) => { @@ -187,7 +196,9 @@ export function createAccountRoutes(pool: AccountPool, scheduler: RefreshSchedul if (entry.status !== "active") { c.status(409); return c.json({ error: `Account is ${entry.status}, cannot query quota` }); } try { const usage = await new CodexApi(entry.token, entry.accountId, cookieJar, id, proxyPool?.resolveProxyUrl(id)).getUsage(); - return c.json({ quota: toQuota(usage), raw: usage }); + const quota = toQuota(usage); + pool.updateCachedQuota(id, quota); + return c.json({ quota, raw: usage }); } catch (err) { // Auto-mark invalidated/banned accounts if (isTokenInvalidError(err)) { diff --git a/src/services/account-transfer-formats.ts b/src/services/account-transfer-formats.ts new file mode 100644 index 00000000..72eb27be --- /dev/null +++ b/src/services/account-transfer-formats.ts @@ -0,0 +1,318 @@ +import type { AccountEntry, CodexQuotaWindow } from "../auth/types.js"; +import { decodeJwtPayload } from "../auth/jwt-utils.js"; +import type { ImportEntry } from "./account-import.js"; + +export type AccountExportFormat = "full" | "minimal" | "cockpit_tools" | "sub2api" | "cpa"; + +type JsonRecord = Record; + +interface Sub2ApiAccountItem { + name: string; + platform: "openai"; + type: "oauth"; + credentials: JsonRecord; + concurrency: number; + priority: number; +} + +interface Sub2ApiExportPayload { + exported_at: string; + proxies: []; + accounts: Sub2ApiAccountItem[]; + type: "sub2api-data"; + version: 1; +} + +interface PortableCodexToken { + access_token: string; + refresh_token?: string; + account_id?: string; + last_refresh?: string; + email?: string; + type: "codex"; + expired?: string; +} + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : null; +} + +function normalizeString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function readPath(value: unknown, path: readonly string[]): unknown { + let current: unknown = value; + for (const key of path) { + const record = toRecord(current); + if (!record || !(key in record)) return undefined; + current = record[key]; + } + return current; +} + +function firstString(value: unknown, paths: readonly (readonly string[])[]): string | undefined { + for (const path of paths) { + const found = normalizeString(readPath(value, path)); + if (found) return found; + } + return undefined; +} + +function normalizeLabel(value: unknown): string | null | undefined { + const label = normalizeString(value); + if (label === undefined) return undefined; + return label.length > 64 ? label.slice(0, 64) : label; +} + +function labelFromValue(value: unknown): string | null | undefined { + return normalizeLabel( + firstString(value, [ + ["label"], + ["name"], + ["account_name"], + ["accountName"], + ["account_note"], + ["accountNote"], + ["note"], + ]), + ); +} + +function looksLikeRefreshToken(value: string): boolean { + const normalized = value.trim(); + return normalized.startsWith("oaistb_rt_") || normalized.startsWith("rt_"); +} + +function normalizeBearer(value: string): string { + const trimmed = value.trim(); + return trimmed.toLowerCase().startsWith("bearer ") + ? trimmed.slice("bearer ".length).trim() + : trimmed; +} + +function candidateFromString(value: string): ImportEntry | null { + const token = normalizeBearer(value); + if (!token) return null; + if (looksLikeRefreshToken(token)) return { refreshToken: token }; + return { token }; +} + +function candidateFromValue(value: unknown, fallbackLabel?: string | null): ImportEntry | null { + if (typeof value === "string") return candidateFromString(value); + + const record = toRecord(value); + if (!record) return null; + + const token = firstString(record, [ + ["token"], + ["access_token"], + ["accessToken"], + ["tokens", "access_token"], + ["tokens", "accessToken"], + ["credentials", "access_token"], + ["credentials", "accessToken"], + ["credentials", "token"], + ]); + const refreshToken = firstString(record, [ + ["refreshToken"], + ["refresh_token"], + ["tokens", "refreshToken"], + ["tokens", "refresh_token"], + ["credentials", "refreshToken"], + ["credentials", "refresh_token"], + ]); + const label = labelFromValue(record) ?? fallbackLabel ?? undefined; + + if (!token && !refreshToken) return null; + + return { + ...(token ? { token: normalizeBearer(token) } : {}), + ...(refreshToken ? { refreshToken } : {}), + ...(label !== undefined ? { label } : {}), + }; +} + +function isSub2ApiAccount(value: unknown): boolean { + const record = toRecord(value); + if (!record) return false; + const platform = normalizeString(record.platform)?.toLowerCase(); + const type = normalizeString(record.type)?.toLowerCase(); + return platform === "openai" && type === "oauth"; +} + +function parseSub2ApiPayload(value: JsonRecord): ImportEntry[] | null { + const accounts = Array.isArray(value.accounts) ? value.accounts : null; + if (!accounts) return null; + const looksLikeSub2Api = + normalizeString(value.type) === "sub2api-data" || + "proxies" in value || + accounts.some((item) => toRecord(item)?.credentials); + if (!looksLikeSub2Api) return null; + + const entries: ImportEntry[] = []; + for (const item of accounts) { + if (!isSub2ApiAccount(item)) continue; + const record = toRecord(item); + const credentials = record ? toRecord(record.credentials) : null; + if (!credentials) continue; + const label = normalizeLabel(record?.name); + const entry = candidateFromValue(credentials, label); + if (entry) entries.push(entry); + } + return entries; +} + +export function parseAccountImportPayload(payload: unknown): ImportEntry[] { + if (Array.isArray(payload)) { + return payload.flatMap((item) => { + const entry = candidateFromValue(item); + return entry ? [entry] : []; + }); + } + + const record = toRecord(payload); + if (!record) { + const entry = candidateFromValue(payload); + return entry ? [entry] : []; + } + + const sub2api = parseSub2ApiPayload(record); + if (sub2api) return sub2api; + + if (Array.isArray(record.accounts)) { + return record.accounts.flatMap((item) => { + const entry = candidateFromValue(item); + return entry ? [entry] : []; + }); + } + + const entry = candidateFromValue(record); + return entry ? [entry] : []; +} + +export function parseAccountImportText(text: string): ImportEntry[] { + const trimmed = text.trim(); + if (!trimmed) return []; + + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return parseAccountImportPayload(JSON.parse(trimmed) as unknown); + } catch { + // Fall through to JSON-lines / token-lines parsing. + } + } + + const entries: ImportEntry[] = []; + for (const line of text.split(/\r?\n/)) { + const normalized = line.trim(); + if (!normalized) continue; + try { + const parsed = JSON.parse(normalized) as unknown; + entries.push(...parseAccountImportPayload(parsed)); + } catch { + const entry = candidateFromString(normalized); + if (entry) entries.push(entry); + } + } + return entries; +} + +export function parseAccountExportFormat(value: string | undefined): AccountExportFormat | null { + if (!value || value === "full") return "full"; + if ( + value === "minimal" || + value === "cockpit_tools" || + value === "sub2api" || + value === "cpa" + ) { + return value; + } + return null; +} + +function isoFromUnixSeconds(value: unknown): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + const date = new Date(value * 1000); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +function accessTokenExpiry(entry: AccountEntry): string | undefined { + return isoFromUnixSeconds(decodeJwtPayload(entry.token)?.exp); +} + +function quotaWindowRemaining(window: CodexQuotaWindow | null | undefined): number | undefined { + return typeof window?.remaining_percent === "number" ? window.remaining_percent : undefined; +} + +function toPortableToken(entry: AccountEntry): PortableCodexToken { + return { + access_token: entry.token, + ...(entry.refreshToken ? { refresh_token: entry.refreshToken } : {}), + ...(entry.accountId ? { account_id: entry.accountId } : {}), + last_refresh: entry.quotaFetchedAt ?? entry.addedAt, + ...(entry.email ? { email: entry.email } : {}), + type: "codex", + ...(accessTokenExpiry(entry) ? { expired: accessTokenExpiry(entry) } : {}), + }; +} + +function toSub2ApiAccount(entry: AccountEntry): Sub2ApiAccountItem { + const credentials: JsonRecord = { + access_token: entry.token, + ...(entry.refreshToken ? { refresh_token: entry.refreshToken } : {}), + ...(entry.email ? { email: entry.email } : {}), + ...(entry.accountId ? { chatgpt_account_id: entry.accountId } : {}), + ...(entry.userId ? { chatgpt_user_id: entry.userId } : {}), + ...(entry.planType ? { plan_type: entry.planType } : {}), + ...(accessTokenExpiry(entry) ? { expires_at: accessTokenExpiry(entry) } : {}), + }; + const primaryRemaining = quotaWindowRemaining(entry.cachedQuota?.rate_limit); + if (primaryRemaining !== undefined) credentials.quota_remaining_percent = primaryRemaining; + + return { + name: entry.label?.trim() || entry.email || entry.id, + platform: "openai", + type: "oauth", + credentials, + concurrency: 0, + priority: 0, + }; +} + +export function buildAccountExportPayload( + entries: AccountEntry[], + format: AccountExportFormat, +): unknown { + if (format === "full") return { accounts: entries }; + if (format === "minimal") { + return { + accounts: entries + .filter((entry) => entry.refreshToken) + .map((entry) => ({ + refreshToken: entry.refreshToken, + ...(entry.label ? { label: entry.label } : {}), + })), + }; + } + if (format === "cockpit_tools") { + return entries.map(toPortableToken); + } + if (format === "sub2api") { + const payload: Sub2ApiExportPayload = { + exported_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + proxies: [], + accounts: entries.map(toSub2ApiAccount), + type: "sub2api-data", + version: 1, + }; + return payload; + } + + const portable = entries.map(toPortableToken); + return portable.length === 1 ? portable[0] : portable; +} diff --git a/tests/unit/auth/quota-utils.test.ts b/tests/unit/auth/quota-utils.test.ts index 44617608..19cd749a 100644 --- a/tests/unit/auth/quota-utils.test.ts +++ b/tests/unit/auth/quota-utils.test.ts @@ -30,6 +30,7 @@ describe("toQuota", () => { expect(quota.rate_limit.used_percent).toBe(42); expect(quota.rate_limit.reset_at).toBe(1700000000); expect(quota.rate_limit.limit_window_seconds).toBe(3600); + expect(quota.rate_limit.remaining_percent).toBe(58); expect(quota.rate_limit.limit_reached).toBe(false); expect(quota.rate_limit.allowed).toBe(true); expect(quota.secondary_rate_limit).toBeNull(); @@ -58,6 +59,7 @@ describe("toQuota", () => { expect(quota.secondary_rate_limit).not.toBeNull(); expect(quota.secondary_rate_limit!.used_percent).toBe(75); + expect(quota.secondary_rate_limit!.remaining_percent).toBe(25); expect(quota.secondary_rate_limit!.reset_at).toBe(1700500000); expect(quota.secondary_rate_limit!.limit_window_seconds).toBe(604800); }); @@ -81,6 +83,7 @@ describe("toQuota", () => { expect(quota.code_review_rate_limit!.allowed).toBe(true); expect(quota.code_review_rate_limit!.limit_reached).toBe(true); expect(quota.code_review_rate_limit!.used_percent).toBe(100); + expect(quota.code_review_rate_limit!.remaining_percent).toBe(0); expect(quota.code_review_rate_limit!.limit_window_seconds).toBe(3600); }); @@ -129,9 +132,11 @@ describe("toQuota", () => { limit_id: "codex_other", limit_name: "Codex Other", used_percent: 12, + remaining_percent: 88, limit_window_seconds: 1800, secondary_rate_limit: { used_percent: 34, + remaining_percent: 66, reset_at: 1700100000, limit_window_seconds: 604800, }, @@ -140,6 +145,7 @@ describe("toQuota", () => { allowed: true, limit_reached: false, used_percent: 7, + remaining_percent: 93, reset_at: 1700003000, limit_window_seconds: 1800, }); @@ -225,6 +231,7 @@ describe("toQuota", () => { })); expect(quota.rate_limit.used_percent).toBeNull(); + expect(quota.rate_limit.remaining_percent).toBeNull(); expect(quota.rate_limit.reset_at).toBeNull(); expect(quota.rate_limit.limit_window_seconds).toBeNull(); }); diff --git a/tests/unit/routes/accounts-import-export.test.ts b/tests/unit/routes/accounts-import-export.test.ts index 7ae5426c..2058f7ea 100644 --- a/tests/unit/routes/accounts-import-export.test.ts +++ b/tests/unit/routes/accounts-import-export.test.ts @@ -143,6 +143,32 @@ describe("account import/export", () => { expect(data.accounts[0]).not.toHaveProperty("email"); }); + it("GET /auth/accounts/export?format=sub2api returns Sub2API-compatible payload", async () => { + const id = pool.addAccount("tokenSUB21234567890", "rt_sub2api"); + pool.setLabel(id, "Sub2API Label"); + + const res = await app.request("/auth/accounts/export?format=sub2api"); + expect(res.status).toBe(200); + const data = await res.json() as { + type: string; + version: number; + accounts: Array<{ + name: string; + platform: string; + type: string; + credentials: { access_token: string; refresh_token?: string }; + }>; + }; + expect(data.type).toBe("sub2api-data"); + expect(data.version).toBe(1); + expect(data.accounts).toHaveLength(1); + expect(data.accounts[0].name).toBe("Sub2API Label"); + expect(data.accounts[0].platform).toBe("openai"); + expect(data.accounts[0].type).toBe("oauth"); + expect(data.accounts[0].credentials.access_token).toBe("tokenSUB21234567890"); + expect(data.accounts[0].credentials.refresh_token).toBe("rt_sub2api"); + }); + it("GET /auth/accounts/export?ids= filters server-side", async () => { const id1 = pool.addAccount("tokenAAAA1234567890"); pool.addAccount("tokenBBBB1234567890"); @@ -154,6 +180,36 @@ describe("account import/export", () => { expect(data.accounts[0].id).toBe(id1); }); + it("GET /auth/accounts/:id/quota caches successful active quota responses", async () => { + const id = pool.addAccount("tokenQUOT1234567890"); + const { CodexApi } = await import("@src/proxy/codex-api.js"); + const getUsageSpy = vi.spyOn(CodexApi.prototype, "getUsage").mockResolvedValueOnce({ + plan_type: "plus", + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: { + used_percent: 64, + reset_at: 1700000000, + limit_window_seconds: 18_000, + reset_after_seconds: 100, + }, + secondary_window: null, + }, + code_review_rate_limit: null, + credits: null, + promo: null, + }); + + const res = await app.request(`/auth/accounts/${id}/quota`); + + expect(res.status).toBe(200); + const body = await res.json() as { quota: { rate_limit: { remaining_percent: number } } }; + expect(body.quota.rate_limit.remaining_percent).toBe(36); + expect(pool.getEntry(id)?.cachedQuota?.rate_limit.remaining_percent).toBe(36); + getUsageSpy.mockRestore(); + }); + // ── Import ───────────────────────────────────────────── it("POST /auth/accounts/import adds new accounts", async () => { @@ -252,6 +308,57 @@ describe("account import/export", () => { expect(entries[0].refreshToken).toBe("refresh_abc"); }); + it("POST /auth/accounts/import accepts Sub2API export JSON", async () => { + const res = await app.request("/auth/accounts/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "sub2api-data", + version: 1, + accounts: [ + { + name: "Imported Sub2API", + platform: "openai", + type: "oauth", + credentials: { + access_token: "tokenS2AI1234567890", + refresh_token: "rt_s2ai", + }, + }, + ], + }), + }); + + expect(res.status).toBe(200); + const data = await res.json() as { added: number; failed: number }; + expect(data.added).toBe(1); + expect(data.failed).toBe(0); + const entry = pool.getAllEntries()[0]; + expect(entry.token).toBe("tokenS2AI1234567890"); + expect(entry.refreshToken).toBe("rt_s2ai"); + expect(entry.label).toBe("Imported Sub2API"); + }); + + it("POST /auth/accounts/import accepts text/plain token lines", async () => { + const res = await app.request("/auth/accounts/import", { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: [ + "tokenTEXT1234567890", + "{\"accessToken\":\"tokenJSON1234567890\",\"refreshToken\":\"rt_json\"}", + ].join("\n"), + }); + + expect(res.status).toBe(200); + const data = await res.json() as { added: number; failed: number }; + expect(data.added).toBe(2); + expect(data.failed).toBe(0); + expect(pool.getAllEntries().map((entry) => entry.token)).toEqual([ + "tokenTEXT1234567890", + "tokenJSON1234567890", + ]); + }); + it("POST /auth/accounts/import rejects empty accounts array", async () => { const res = await app.request("/auth/accounts/import", { method: "POST", diff --git a/tests/unit/routes/shared/proxy-rate-limit.test.ts b/tests/unit/routes/shared/proxy-rate-limit.test.ts index 005cfa84..bb5fea39 100644 --- a/tests/unit/routes/shared/proxy-rate-limit.test.ts +++ b/tests/unit/routes/shared/proxy-rate-limit.test.ts @@ -46,6 +46,7 @@ describe("applyParsedRateLimits", () => { plan_type: "team", rate_limit: { used_percent: 42, + remaining_percent: 58, reset_at: 1_700_000_000, limit_window_seconds: 18_000, allowed: true, @@ -53,6 +54,7 @@ describe("applyParsedRateLimits", () => { }, secondary_rate_limit: { used_percent: 18, + remaining_percent: 82, reset_at: 1_700_500_000, limit_window_seconds: 604_800, limit_reached: false, diff --git a/tests/unit/services/account-transfer-formats.test.ts b/tests/unit/services/account-transfer-formats.test.ts new file mode 100644 index 00000000..c60193de --- /dev/null +++ b/tests/unit/services/account-transfer-formats.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryPersistence } from "@helpers/account-pool-factory.js"; +import { createValidJwt } from "@helpers/jwt.js"; +import { AccountPool } from "@src/auth/account-pool.js"; +import { + buildAccountExportPayload, + parseAccountImportPayload, + parseAccountImportText, +} from "@src/services/account-transfer-formats.js"; + +function makePool(): AccountPool { + return new AccountPool({ + persistence: createMemoryPersistence(), + rotationStrategy: "least_used", + initialToken: null, + rateLimitBackoffSeconds: 300, + }); +} + +describe("account transfer formats", () => { + it("parses Cockpit Tools portable token objects", () => { + const entries = parseAccountImportPayload([ + { + access_token: "access.jwt.token", + refresh_token: "rt_portable", + email: "user@example.com", + }, + { + tokens: { + accessToken: "nested.jwt.token", + refreshToken: "rt_nested", + }, + label: "Nested", + }, + ]); + + expect(entries).toEqual([ + { token: "access.jwt.token", refreshToken: "rt_portable" }, + { token: "nested.jwt.token", refreshToken: "rt_nested", label: "Nested" }, + ]); + }); + + it("parses Sub2API OpenAI OAuth exports", () => { + const entries = parseAccountImportPayload({ + type: "sub2api-data", + version: 1, + proxies: [], + accounts: [ + { + name: "Team Alpha", + platform: "openai", + type: "oauth", + credentials: { + access_token: "sub2api.jwt.token", + refresh_token: "rt_sub2api", + }, + concurrency: 0, + priority: 0, + }, + { + name: "Ignored Anthropic", + platform: "anthropic", + type: "oauth", + credentials: { access_token: "anthropic-token" }, + }, + ], + }); + + expect(entries).toEqual([ + { + token: "sub2api.jwt.token", + refreshToken: "rt_sub2api", + label: "Team Alpha", + }, + ]); + }); + + it("parses text token lines and one-json-object-per-line input", () => { + const entries = parseAccountImportText([ + "{\"accessToken\":\"json.jwt.token\",\"refreshToken\":\"rt_json\",\"label\":\"JSON\"}", + "plain.access.token", + "rt_text_only", + ].join("\n")); + + expect(entries).toEqual([ + { token: "json.jwt.token", refreshToken: "rt_json", label: "JSON" }, + { token: "plain.access.token" }, + { refreshToken: "rt_text_only" }, + ]); + }); + + it("exports Cockpit Tools, Sub2API, and CPA payloads", () => { + const pool = makePool(); + const token = createValidJwt({ + accountId: "acct-1", + userId: "user-1", + email: "alpha@example.com", + planType: "plus", + }); + const entryId = pool.addAccount(token, "rt_alpha"); + pool.setLabel(entryId, "Alpha"); + const entries = pool.getAllEntries(); + + const cockpit = buildAccountExportPayload(entries, "cockpit_tools"); + expect(cockpit).toEqual([ + expect.objectContaining({ + access_token: token, + refresh_token: "rt_alpha", + account_id: "acct-1", + email: "alpha@example.com", + type: "codex", + }), + ]); + + const sub2api = buildAccountExportPayload(entries, "sub2api"); + expect(sub2api).toEqual(expect.objectContaining({ + type: "sub2api-data", + version: 1, + accounts: [ + expect.objectContaining({ + name: "Alpha", + platform: "openai", + type: "oauth", + credentials: expect.objectContaining({ + access_token: token, + refresh_token: "rt_alpha", + chatgpt_account_id: "acct-1", + }), + }), + ], + })); + + const cpa = buildAccountExportPayload(entries, "cpa"); + expect(cpa).toEqual(expect.objectContaining({ + access_token: token, + refresh_token: "rt_alpha", + account_id: "acct-1", + type: "codex", + })); + }); +}); diff --git a/web/src/components/AccountImportExport.test.tsx b/web/src/components/AccountImportExport.test.tsx new file mode 100644 index 00000000..93b94cab --- /dev/null +++ b/web/src/components/AccountImportExport.test.tsx @@ -0,0 +1,45 @@ +/** @vitest-environment jsdom */ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/preact"; + +const mockI18n = vi.hoisted(() => ({ + useT: vi.fn(), +})); + +vi.mock("../../../shared/i18n/context", () => ({ + useT: () => mockI18n.useT(), +})); + +import { AccountImportExport } from "./AccountImportExport"; + +describe("AccountImportExport", () => { + beforeEach(() => { + mockI18n.useT.mockReturnValue((key: string) => key); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("exports with the selected compatibility format", async () => { + const onExport = vi.fn(async () => undefined); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("exportFormat"), { + target: { value: "sub2api" }, + }); + fireEvent.click(screen.getByTitle("exportBtn (1)")); + + await waitFor(() => { + expect(onExport).toHaveBeenCalledWith(["acct-1"], "sub2api"); + }); + }); +}); diff --git a/web/src/components/AccountImportExport.tsx b/web/src/components/AccountImportExport.tsx index e54edd7c..ecd5f686 100644 --- a/web/src/components/AccountImportExport.tsx +++ b/web/src/components/AccountImportExport.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useRef } from "preact/hooks"; import { useT } from "../../../shared/i18n/context"; +import type { AccountExportFormat } from "../../../shared/account-transfer-client"; interface ImportResult { success: boolean; @@ -10,7 +11,7 @@ interface ImportResult { } interface AccountImportExportProps { - onExport: (selectedIds?: string[], format?: "full" | "minimal") => Promise; + onExport: (selectedIds?: string[], format?: AccountExportFormat) => Promise; onImport: (file: File) => Promise; selectedIds: Set; } @@ -20,15 +21,16 @@ export function AccountImportExport({ onExport, onImport, selectedIds }: Account const fileRef = useRef(null); const [importing, setImporting] = useState(false); const [result, setResult] = useState(null); + const [exportFormat, setExportFormat] = useState("full"); - const handleExport = useCallback(async (format?: "full" | "minimal") => { + const handleExport = useCallback(async () => { try { const ids = selectedIds.size > 0 ? [...selectedIds] : undefined; - await onExport(ids, format); + await onExport(ids, exportFormat); } catch (err) { console.error("[AccountExport] failed:", err); } - }, [onExport, selectedIds]); + }, [exportFormat, onExport, selectedIds]); const handleFileChange = useCallback(async () => { const files = fileRef.current?.files; @@ -89,7 +91,7 @@ export function AccountImportExport({ onExport, onImport, selectedIds }: Account + - {selectedIds.size > 0 && (