Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<startupMs>.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
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 登录门
Expand Down Expand Up @@ -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 | 批量检测账号可用性 |
Expand Down Expand Up @@ -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" \
Expand All @@ -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" \
Expand Down
61 changes: 61 additions & 0 deletions shared/account-transfer-client.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
67 changes: 67 additions & 0 deletions shared/account-transfer-client.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}

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<PreparedAccountImportRequest> {
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 };
}
45 changes: 14 additions & 31 deletions shared/hooks/use-accounts.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions shared/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down Expand Up @@ -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: "刷新中...",
Expand Down
2 changes: 2 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading
Loading