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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

### Added

- Dashboard 新增 Plus / Pro / PAYG 账号的 credit 余额可视化与账号池总览(Phase 1,零新增上游 traffic):`CodexUsageResponse.credits` 此前被打成 `unknown` 全量丢弃,现在 `toQuota()` 把 `has_credits / unlimited / overage_limit_reached / balance` 解析进新 `CodexQuota.credits` 槽,并把 `balance` 从 upstream 的十进制字符串转成 number(malformed payload 防御性返回 null 而不是 NaN);`AccountRegistry.updateCachedQuota` 在收到不带 credits 字段的 quota(passive header path)时保留之前已知的 credit 余额,防止每次 `/codex/responses` 响应抹掉初始 warmup 拿到的余额;新增 `usage_stats.credits_per_usd` 配置项(默认 25,即 1000 credits = $40),dashboard 端按此换算 USD 显示。前端 `AccountCard` 在 `has_credits=true` 或 `unlimited=true` 的账号上新增一行 Credit Balance(balance + USD 换算 或 "Unlimited" 标签 或 overage 红字提示),Plus 账号 has_credits=false 时整行不渲染;新增 `PoolOverview` 卡片在 `/` 主页 AccountList 上方汇总账号池:active / quota_exhausted 数量、所有携带 credits 的账号余额合计与 USD 折算、secondary used% 最高的账号 + 重置时间。新增 `formatCredits` / `creditsToUsd` / `formatUsd` 共享格式化工具,shared 类型 `AccountQuota.credits` 透传到前端。新增 `tests/unit/auth/quota-utils.test.ts` 5 个 credits 用例、`tests/unit/auth/account-pool-quota.test.ts` 2 个 updateCachedQuota credits-preserve 用例、`shared/utils/__tests__/format-credits.test.ts` 11 个格式化工具用例、`tests/unit/web/pool-overview-stats.test.ts` 7 个聚合算法用例、`tests/unit/config-schema.test.ts` 补 `credits_per_usd` 默认值断言。i18n 中英新增 8 个新键(`src/proxy/codex-types.ts`、`src/auth/types.ts`、`src/auth/quota-utils.ts`、`src/auth/account-registry.ts`、`src/config-schema.ts`、`src/proxy/codex-api.ts`、`shared/types.ts`、`shared/utils/format.ts`、`shared/i18n/translations.ts`、`web/src/App.tsx`、`web/src/components/AccountCard.tsx`、`web/src/components/PoolOverview.tsx`)
- 支持 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 All @@ -19,6 +20,7 @@

### Fixed

- Dashboard 的"刷新"按钮现在真正刷新账号 quota:`GET /auth/accounts/:id/quota` 此前只把 `/codex/usage` 结果返回给 caller、不写回 `pool.cachedQuota`;且前端 AccountCard 的 Refresh 按钮只 POST 了 `/auth/accounts/:id/refresh`(仅刷新 access token,不动 quota)。所以当 OpenAI 做 promo / window grant 把 `secondary_window.used_percent` 重置为 0% 时,proxy 仍然显示重置前的 98–100%,要等下一次真实 `/codex/responses` 请求才被动靠 `x-codex-*` 响应头追上。同一条死路也让 Pro / PAYG 账号 `credits` 块永远进不了 cachedQuota(header path 根本不带 credits)。修复:`/auth/accounts/:id/quota` 拉取上游后立刻 `pool.updateCachedQuota(id, toQuota(usage))` 写回;前端 `AccountList.onRefreshQuota` 在 `/refresh` 后串联 `GET /quota`,UI 点击一次 Refresh 就把 token 和 quota 同时拉新(`src/routes/accounts.ts`、`web/src/components/AccountList.tsx`)
- `/v1/responses` passthrough streaming / non-streaming paths now collect `function_call.call_id` from `response.output_item.done` and forward it through response metadata so implicit resume can validate following `function_call_output` turns instead of falling back to full-history replay. Oversized missing-tool-call replays are guarded with 413, and regression coverage now proves the issue red/green across the Responses format adapter (`src/routes/responses.ts`, `src/routes/shared/proxy-handler.ts`, `tests/unit/routes/responses-passthrough-metadata.test.ts`, `tests/integration/proxy-handler.test.ts`).
- Release bump workflows now require runtime file changes in addition to meaningful commit subjects before tagging a beta or stable build. This prevents squash-promotion history divergence from re-counting old dev commits, and prevents workflow/docs/test-only fixes from producing empty Electron releases (`.github/workflows/bump-electron.yml`, `.github/workflows/bump-electron-beta.yml`, `tests/unit/ci/package-boundary.test.ts`).
- Release bump workflows now skip the release-notes workflow hotfix subject itself, so promoting the stable-notes CI fix to `master` does not create an empty desktop release on the next scheduled bump (`.github/workflows/bump-electron.yml`, `.github/workflows/bump-electron-beta.yml`, `tests/unit/ci/package-boundary.test.ts`).
Expand Down
16 changes: 16 additions & 0 deletions shared/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ export const translations = {
secondaryRateLimit: "Weekly Limit",
reviewRateLimit: "Review Limit",
additionalRateLimit: "Additional Limit",
creditsBalance: "Credit Balance",
creditsUnlimited: "Unlimited",
creditsOverageReached: "Overage limit reached",
poolOverview: "Pool Overview",
poolActiveAccounts: "Active",
poolExhaustedAccounts: "Quota Exhausted",
poolTotalCredits: "Total Credits",
poolTopUsage: "Highest Weekly Usage",
limitReached: "Limit Reached",
used: "Used",
ok: "OK",
Expand Down Expand Up @@ -434,6 +442,14 @@ export const translations = {
secondaryRateLimit: "\u5468\u9650\u5236",
reviewRateLimit: "Review \u989d\u5ea6",
additionalRateLimit: "\u989d\u5916\u989d\u5ea6",
creditsBalance: "Credit \u4f59\u989d",
creditsUnlimited: "\u65e0\u9650",
creditsOverageReached: "\u8d85\u9650\u62cd\u540e\u5df2\u5230\u4e0a\u9650",
poolOverview: "\u8d26\u53f7\u6c60\u603b\u89c8",
poolActiveAccounts: "\u6d3b\u8dc3",
poolExhaustedAccounts: "\u989d\u5ea6\u8017\u5c3d",
poolTotalCredits: "Credit \u603b\u4f59\u989d",
poolTopUsage: "\u5468\u9650\u4f7f\u7528\u7387\u6700\u9ad8\u8d26\u53f7",
limitReached: "\u5df2\u8fbe\u4e0a\u9650",
used: "\u5df2\u4f7f\u7528",
ok: "\u6b63\u5e38",
Expand Down
10 changes: 10 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ export interface AccountQuotaWindow {
limit_window_seconds?: number | null;
}

export interface AccountQuotaCredits {
has_credits: boolean;
unlimited: boolean;
overage_limit_reached: boolean;
/** Numeric balance parsed from upstream's decimal-string field. */
balance: number;
}

export interface AccountQuota {
rate_limit?: AccountQuotaWindow;
secondary_rate_limit?: AccountQuotaWindow | null;
Expand All @@ -15,6 +23,8 @@ export interface AccountQuota {
allowed?: boolean;
secondary_rate_limit?: AccountQuotaWindow | null;
}> | null;
/** Credit accounting from /codex/usage. Null for Plus, present for Pro / PAYG. */
credits?: AccountQuotaCredits | null;
}

export interface QuotaWarning {
Expand Down
61 changes: 61 additions & 0 deletions shared/utils/__tests__/format-credits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { formatCredits, creditsToUsd, formatUsd } from "../format";

describe("formatCredits", () => {
it("renders zero as plain '0'", () => {
expect(formatCredits(0)).toBe("0");
});

it("strips trailing zeros for round numbers", () => {
expect(formatCredits(5)).toBe("5");
expect(formatCredits(247.5)).toBe("247.5");
});

it("rounds small decimals to two places", () => {
expect(formatCredits(12.345)).toBe("12.35");
expect(formatCredits(0.05)).toBe("0.05");
});

it("uses k suffix above 1000", () => {
expect(formatCredits(3196)).toBe("3.2k");
expect(formatCredits(7000)).toBe("7k");
});

it("returns '0' for non-finite input", () => {
expect(formatCredits(NaN)).toBe("0");
expect(formatCredits(Infinity)).toBe("0");
});
});

describe("creditsToUsd", () => {
it("converts at the default rate (25 credits = $1)", () => {
expect(creditsToUsd(25, 25)).toBe(1);
expect(creditsToUsd(1000, 25)).toBe(40);
});

it("returns null when rate is zero or negative (USD display disabled)", () => {
expect(creditsToUsd(500, 0)).toBeNull();
expect(creditsToUsd(500, -1)).toBeNull();
});

it("returns null for non-finite inputs", () => {
expect(creditsToUsd(NaN, 25)).toBeNull();
expect(creditsToUsd(100, NaN)).toBeNull();
});
});

describe("formatUsd", () => {
it("formats with $ sign and two decimals", () => {
expect(formatUsd(0)).toBe("$0.00");
expect(formatUsd(12.345)).toBe("$12.35");
});

it("uses k suffix above $1000", () => {
expect(formatUsd(1234.56)).toBe("$1.2k");
expect(formatUsd(40000)).toBe("$40k");
});

it("handles negatives", () => {
expect(formatUsd(-12.34)).toBe("-$12.34");
});
});
27 changes: 27 additions & 0 deletions shared/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ export function formatNumber(n: number): string {
return String(n);
}

/** Format a Codex credit balance: "0", "12.34", "1.2k". Always strip trailing zeros. */
export function formatCredits(credits: number): string {
if (!Number.isFinite(credits)) return "0";
if (credits >= 1000) return (credits / 1000).toFixed(1).replace(/\.0$/, "") + "k";
if (credits === 0) return "0";
// Two decimals for small numbers, but trim trailing zeros so 5.00 → "5".
return credits.toFixed(2).replace(/\.?0+$/, "");
}

/** Convert credits to USD using the configured per-USD rate.
* Returns null when conversion is disabled (creditsPerUsd <= 0). */
export function creditsToUsd(credits: number, creditsPerUsd: number): number | null {
if (!Number.isFinite(credits) || !Number.isFinite(creditsPerUsd) || creditsPerUsd <= 0) {
return null;
}
return credits / creditsPerUsd;
}

/** Format a USD amount with $ sign and two decimals. "$12.34" / "$1.2k". */
export function formatUsd(usd: number): string {
if (!Number.isFinite(usd)) return "$0";
const sign = usd < 0 ? "-" : "";
const abs = Math.abs(usd);
if (abs >= 1000) return sign + "$" + (abs / 1000).toFixed(1).replace(/\.0$/, "") + "k";
return sign + "$" + abs.toFixed(2);
}

export function formatWindowDuration(seconds: number, isZh: boolean): string {
if (seconds >= 86400) {
const days = Math.floor(seconds / 86400);
Expand Down
10 changes: 9 additions & 1 deletion src/auth/account-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,15 @@ export class AccountRegistry {
updateCachedQuota(entryId: string, quota: CodexQuota): void {
const entry = this.accounts.get(entryId);
if (!entry) return;
entry.cachedQuota = quota;
// Preserve previously known credits when the incoming quota lacks them.
// The passive header-driven path (rateLimitToQuota in proxy-rate-limit.ts)
// does not carry credit balance — only /codex/usage body (toQuota) does.
// Without this merge, every /codex/responses call would wipe credits.
if (quota.credits === undefined && entry.cachedQuota?.credits != null) {
entry.cachedQuota = { ...quota, credits: entry.cachedQuota.credits };
} else {
entry.cachedQuota = quota;
}
entry.quotaFetchedAt = new Date().toISOString();
this.schedulePersist();
}
Expand Down
21 changes: 19 additions & 2 deletions src/auth/quota-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,24 @@
* Converts CodexUsageResponse (raw backend) → CodexQuota (normalized).
*/

import type { CodexQuota } from "./types.js";
import type { CodexUsageRateLimit, CodexUsageResponse } from "../proxy/codex-api.js";
import type { CodexQuota, CodexQuotaCredits } from "./types.js";
import type { CodexUsageCredits, CodexUsageRateLimit, CodexUsageResponse } from "../proxy/codex-api.js";

function normalizeCredits(raw: CodexUsageCredits | null | undefined): CodexQuotaCredits | null {
if (!raw) return null;
// balance must be parseable — upstream always sends a decimal string,
// but defensively reject malformed payloads so the dashboard never
// shows NaN credits.
if (typeof raw.balance !== "string") return null;
const balance = Number(raw.balance);
if (!Number.isFinite(balance)) return null;
return {
has_credits: Boolean(raw.has_credits),
unlimited: Boolean(raw.unlimited),
overage_limit_reached: Boolean(raw.overage_limit_reached),
balance,
};
}

function isReviewLimitId(value: string | null | undefined): boolean {
const normalized = (value ?? "").trim().toLowerCase().replace(/[-\s]+/g, "_");
Expand Down Expand Up @@ -82,5 +98,6 @@ export function toQuota(usage: CodexUsageResponse): CodexQuota {
rate_limits_by_limit_id: Object.keys(rateLimitsByLimitId).length > 0
? rateLimitsByLimitId
: null,
credits: normalizeCredits(usage.credits),
};
}
11 changes: 11 additions & 0 deletions src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ export interface CodexQuotaWindow {
limit_window_seconds: number | null;
}

/** Normalized credit accounting for an account. */
export interface CodexQuotaCredits {
has_credits: boolean;
unlimited: boolean;
overage_limit_reached: boolean;
/** Numeric balance parsed from the upstream decimal-string field. */
balance: number;
}

/** Official Codex quota from /backend-api/codex/usage */
export interface CodexQuota {
plan_type: string;
Expand All @@ -116,6 +125,8 @@ export interface CodexQuota {
reset_at: number | null;
limit_window_seconds: number | null;
} | null;
/** Credit accounting (Pro / PAYG only — null for Plus). */
credits?: CodexQuotaCredits | null;
/** All metered quota buckets returned by Codex app's /wham/usage additional_rate_limits. */
rate_limits_by_limit_id?: Record<string, {
limit_id: string;
Expand Down
4 changes: 4 additions & 0 deletions src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export const ConfigSchema = z.object({
snapshot_interval_minutes: z.number().int().min(0).default(5),
/** null means keep usage history forever. */
history_retention_days: z.number().int().positive().nullable().default(null),
/** Conversion rate for displaying Codex credits as USD on the dashboard.
* Default 25 matches the public rate card (1000 credits = $40 → $0.04/credit).
* Set to 0 to suppress USD rendering and only show raw credit numbers. */
credits_per_usd: z.number().min(0).default(25),
}).default({}),
session: z.object({
ttl_minutes: z.number().min(1).default(1440),
Expand Down
3 changes: 3 additions & 0 deletions src/proxy/codex-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export type {
CodexUsageRateWindow,
CodexUsageRateLimit,
CodexUsageResponse,
CodexUsageCredits,
CodexUsageSpendControl,
CodexUsageRateLimitReachedType,
} from "./codex-types.js";

// Re-export SSE utilities for consumers that used them via CodexApi
Expand Down
32 changes: 30 additions & 2 deletions src/proxy/codex-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,41 @@ export interface CodexUsageAdditionalRateLimit {
rate_limit: CodexUsageRateLimit | null;
}

/** Credit accounting block from /backend-api/codex/usage.
* Populated for Pro / Pay-As-You-Go accounts; for Plus accounts the
* block is present but has_credits=false and balance="0". */
export interface CodexUsageCredits {
has_credits: boolean;
unlimited: boolean;
overage_limit_reached: boolean;
/** Decimal string. Upstream returns "0", "12.345", etc. */
balance: string;
/** Approximate remaining messages, tuple of [low, high]. */
approx_local_messages?: [number, number];
approx_cloud_messages?: [number, number];
}

/** Per-account spend control (if user set a hard limit). */
export interface CodexUsageSpendControl {
reached: boolean;
individual_limit: number | string | null;
}

/** Diagnostic about which limit type was hit when limit_reached=true. */
export interface CodexUsageRateLimitReachedType {
type: string;
details: string | null;
}

export interface CodexUsageResponse {
plan_type: string;
rate_limit: CodexUsageRateLimit;
code_review_rate_limit: CodexUsageRateLimit | null;
additional_rate_limits?: CodexUsageAdditionalRateLimit[] | null;
credits: unknown;
promo: unknown;
credits?: CodexUsageCredits | null;
spend_control?: CodexUsageSpendControl | null;
rate_limit_reached_type?: CodexUsageRateLimitReachedType | null;
promo?: unknown;
}

export class CodexApiError extends Error {
Expand Down
Loading
Loading