diff --git a/docs-site/public/pr-screenshots/codex-account-usage.png b/docs-site/public/pr-screenshots/codex-account-usage.png
new file mode 100644
index 0000000000..5d4cbf87b5
Binary files /dev/null and b/docs-site/public/pr-screenshots/codex-account-usage.png differ
diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md
index 896a38bb9d..a32643fbb6 100644
--- a/docs-site/src/content/docs/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/guides/web-dashboard.md
@@ -131,6 +131,13 @@ and other providers.
- **Refresh quotas** re-reads account usage immediately so routing and the account cards use the same
values.
- Pool request logs use opaque labels such as `p3fa91c`, never account emails.
+- Each account card also shows that stable log label, the observed 30-day token total, an approximate
+ API-equivalent cost using currently configured display pricing, and the fraction of attempts with
+ measured usage. Active user `modelCosts` overlays take priority over bundled verified catalog and
+ price fallbacks, and historical usage is re-estimated from the pricing active when the summary is
+ read. The cost is an estimate for reconciliation, not a ChatGPT Plus/Pro subscription invoice.
+ Historical bare `openai` rows that predate explicit attribution remain ambiguous rather than being
+ assigned to the current main account.
- **Target a specific Codex account from the model picker** is an explicit opt-in. When enabled,
ordinary supported GPT picker rows are replaced by one entry per public account selector.
Choosing one locks that conversation to the mapped account: it does not rotate, fall back, or
diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md
index dde75b2703..fdc6ceedc2 100644
--- a/docs-site/src/content/docs/reference/management-api.md
+++ b/docs-site/src/content/docs/reference/management-api.md
@@ -124,7 +124,7 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou
| `GET /api/debug/usage-logs` | Read bounded usage-debug entries | — |
| `GET /api/debug/injection-logs` | Read bounded guidance-injection debug entries | — |
| `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — |
-| `GET /api/usage` | Summarize usage by range and client surface | Returns an `error: "read_failed"` summary if storage cannot be read |
+| `GET /api/usage` | Summarize usage by range and client surface; Codex responses also include an `accounts` breakdown keyed by stable non-PII log labels | Returns an `error: "read_failed"` summary if storage cannot be read |
| `GET /api/storage` | Scan Codex storage usage by bucket | Returns an `error: "scan_failed"` payload on scan failure |
| `POST /api/storage/cleanup/preview` | Preview archived-session cleanup and return a binding digest | 400 `invalid_json` or `invalid_percent` |
| `POST /api/storage/cleanup` | Quarantine or permanently remove the previewed archived set | 400 invalid input; 409 stale/busy/referenced state; 500 filesystem/database failure |
@@ -135,6 +135,14 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou
| `POST /api/storage/cleanup-policy/run` | Start a manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` |
| `GET /api/storage/cleanup-policy/test-stream` | Test-only policy stream hook | 404 `not_found` when unavailable |
+For `GET /api/usage?range=30d&surface=codex`, `accounts` contains one row per observed Codex
+pool label. Each row reports `accountLogLabel`, token totals, `usageCoverageRatio`, and an optional
+`estimatedCostUsd` based on the currently configured display pricing. Active user `modelCosts`
+overlays take priority over bundled verified catalog and price fallbacks, and historical usage is
+re-estimated from the pricing active when the summary is read. This is an API-equivalent estimate,
+not a subscription charge. New main-pool requests use the reserved `main` label; legacy bare
+`openai` rows remain in an ambiguous bucket instead of being reassigned from current configuration.
+
:::caution
Storage cleanup endpoints can move or permanently remove archived session data. Always preview
first and submit the returned digest. Prefer quarantine when recovery may be needed.
diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx
index 14a68b9e25..22e8273454 100644
--- a/gui/src/components/codex-account-pool-cards.tsx
+++ b/gui/src/components/codex-account-pool-cards.tsx
@@ -1,4 +1,4 @@
-import { useT } from "../i18n/shared";
+import { useI18n, useT } from "../i18n/shared";
import { IconAlert, IconPause, IconPlay, IconX } from "../icons";
import { displayAccountId } from "../lib/privacy";
import AccountPriorityControl, { AccountPriorityBadge } from "./AccountPriorityControl";
@@ -15,6 +15,7 @@ import {
oauthHealthShowsDoctor,
oauthHealthShowsReauth,
} from "../oauth-health-display";
+import { formatCostUsd, formatTokenCount } from "../provider-workspace/usage";
export function CodexAccountPoolCards({
pool,
@@ -65,6 +66,7 @@ export function CodexAccountPoolCards({
doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null;
}) {
const t = useT();
+ const { locale } = useI18n();
const isNext = (account: CodexAccountEntry) => !account.paused && activeId === account.id;
return (
@@ -144,6 +146,14 @@ export function CodexAccountPoolCards({
{a.email}{a.plan ? ` · ${a.plan}` : ""} · {t("prov.accountId")}: {displayAccountId(a.id)}
+ {a.logLabel && (
+ {t("codexAuth.logLabel")}: {a.logLabel}
+ )}
+ {a.usage30d && (
+
+ {t("usage.card.totalTokens")}: {formatTokenCount(a.usage30d.totalTokens, locale)} · {t("pws.estimatedCost")}: {formatCostUsd(a.usage30d.estimatedCostUsd, locale)} · {t("usage.coverage.measured")}: {Math.round(a.usage30d.usageCoverageRatio * 100)}%
+
+ )}
{healthSummary && (
{healthSummary}
)}
diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx
index 4889844629..65b3b96a79 100644
--- a/gui/src/components/codex-account-pool-main-card.tsx
+++ b/gui/src/components/codex-account-pool-main-card.tsx
@@ -7,6 +7,7 @@ import type { CodexAccountEntry } from "./codex-account-pool-types";
import type { CodexAccountModeState } from "../codex-multi-state";
import type { TFn } from "../i18n/shared";
import type { NoticeTone } from "../ui";
+import { useI18n } from "../i18n/shared";
import {
doctorCopyButtonLabel,
formatOAuthHealthLabel,
@@ -16,6 +17,7 @@ import {
oauthHealthShowsDoctor,
oauthHealthShowsReauth,
} from "../oauth-health-display";
+import { formatCostUsd, formatTokenCount } from "../provider-workspace/usage";
export function CodexAccountPoolMainCard({
t,
@@ -60,6 +62,7 @@ export function CodexAccountPoolMainCard({
onCopyDoctor?: (accountId: string) => void;
doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null;
}) {
+ const { locale } = useI18n();
const mainFallbackLabel = t("codexAuth.codexApp");
const mainId = main?.id ?? "__main__";
const mainSwitchEntry: CodexAccountEntry = {
@@ -135,6 +138,12 @@ export function CodexAccountPoolMainCard({
{t("codexAuth.appLogin")}
{main?.email || t("codexAuth.appLogin")}{main?.plan ? ` · ${main.plan}` : ""}
+ {t("codexAuth.logLabel")}: {main?.logLabel ?? "main"}
+ {main?.usage30d && (
+
+ {t("usage.card.totalTokens")}: {formatTokenCount(main.usage30d.totalTokens, locale)} · {t("pws.estimatedCost")}: {formatCostUsd(main.usage30d.estimatedCostUsd, locale)} · {t("usage.coverage.measured")}: {Math.round(main.usage30d.usageCoverageRatio * 100)}%
+
+ )}
{healthSummary && (
{healthSummary}
)}
diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts
index 84e5c2eb76..d894594aef 100644
--- a/gui/src/hooks/useCodexAccountPool.ts
+++ b/gui/src/hooks/useCodexAccountPool.ts
@@ -1,5 +1,6 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { normalizeAccountPriority } from "../account-priority";
+import { useKeyedClientResource } from "../client-resource";
import { extractAutoSwitchThresholdPayload } from "../codex-auto-switch";
import type { AccountQuota } from "../codex-quota-utils";
import { accountNeedsReauth } from "../oauth-health-display";
@@ -24,6 +25,8 @@ export interface CodexAccountEntry {
id: string;
email: string;
alias?: string;
+ /** Stable non-PII identity shared with Logs and per-account usage aggregation. */
+ logLabel?: string;
plan?: string;
/** Required, not optional: the API always distinguishes the app-login row. */
isMain: boolean;
@@ -38,6 +41,11 @@ export interface CodexAccountEntry {
healthLabel?: string;
healthSummary?: string;
healthAction?: string;
+ usage30d?: {
+ totalTokens: number;
+ estimatedCostUsd?: number;
+ usageCoverageRatio: number;
+ };
}
export type CodexAccountLoadState = "loading" | "ready" | "error";
@@ -104,6 +112,18 @@ export interface CodexAccountPoolController {
}
const REFRESH_INTERVAL_MS = 30_000;
+const USAGE_REFRESH_INTERVAL_MS = 60_000;
+
+interface CodexAccountUsageRow {
+ accountLogLabel: string;
+ totalTokens: number;
+ estimatedCostUsd?: number;
+ usageCoverageRatio: number;
+}
+
+interface CodexAccountUsageSummary {
+ accounts?: CodexAccountUsageRow[];
+}
/** In-memory last-good snapshot (not sessionStorage — accounts carry emails/ids). */
const lastGoodByBase = new Map();
@@ -111,6 +131,16 @@ const lastGoodByBase = new Map(() => seed?.accounts ?? []);
+ const usage30d = useKeyedClientResource(
+ `codex-account-usage-30d:${apiBase}`,
+ [apiBase],
+ async (signal) => {
+ const response = await fetch(`${apiBase}/api/usage?range=30d&surface=codex`, { signal });
+ if (!response.ok) throw new Error("account usage load failed");
+ return response.json() as Promise;
+ },
+ { enabled, pollMs: USAGE_REFRESH_INTERVAL_MS },
+ );
const [activeId, setActiveId] = useState(() => seed?.activeId ?? null);
const [loadState, setLoadState] = useState(() => (seed != null ? "ready" : "loading"));
const [switchingId, setSwitchingId] = useState(null);
@@ -196,10 +226,14 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
if (loadGenerationRef.current === generation) {
// Selection order is required downstream (badge, select). Normalizing here keeps
// a payload without it from rendering a NaN order on every card.
- nextAccounts = ((payload.accounts ?? []) as CodexAccountEntry[]).map(account => ({
- ...account,
- priority: normalizeAccountPriority(account.priority),
- }));
+ nextAccounts = ((payload.accounts ?? []) as CodexAccountEntry[]).map(account => {
+ const logLabel = account.isMain ? "main" : account.logLabel;
+ return {
+ ...account,
+ ...(logLabel ? { logLabel } : {}),
+ priority: normalizeAccountPriority(account.priority),
+ };
+ });
setAccounts(nextAccounts);
hasAccountsRef.current = nextAccounts.length > 0;
hasLoadedRef.current = true;
@@ -518,9 +552,19 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
// Include health-only reauth so Providers overview attention matches row CTAs.
const activeAccount = activePoolAccount ?? mainAccount;
const activeNeedsReauth = !activeAccount?.paused && accountNeedsReauth(activeAccount);
+ const accountsWithUsage = useMemo(() => {
+ const usageByLabel = new Map(
+ (usage30d.data?.accounts ?? []).map(row => [row.accountLogLabel, row] as const),
+ );
+ return accounts.map(account => {
+ const logLabel = account.isMain ? "main" : account.logLabel;
+ const accountUsage = logLabel ? usageByLabel.get(logLabel) : undefined;
+ return accountUsage ? { ...account, usage30d: accountUsage } : account;
+ });
+ }, [accounts, usage30d.data]);
return {
- accounts,
+ accounts: accountsWithUsage,
activeId,
loadState,
refreshing: inflightCount > 0,
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 4e422e964c..f59ffde6e3 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -919,6 +919,7 @@ export const de: Record = {
"integrations.semantics.kimi": "Zum Anwenden neu starten oder /reload ausführen (v2 überwacht die Datei).",
"integrations.semantics.gajae": "Gilt für eine neue Sitzung oder beim Öffnen von /model.",
"codexAuth.mainAccount": "Hauptkonto",
+ "codexAuth.logLabel": "Log-Kennung",
"codexAuth.codexApp": "Codex App",
"codexAuth.appLogin": "App-Login",
"codexAuth.accountPool": "Kontopool",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index 897aecbc92..09290a3a91 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -1381,6 +1381,7 @@ export const en = {
"integrations.semantics.kimi": "Restart or run /reload to apply it (v2 watches the file).",
"integrations.semantics.gajae": "Applies to a new session or when opening /model.",
"codexAuth.mainAccount": "Main Account",
+ "codexAuth.logLabel": "Log label",
"codexAuth.codexApp": "Codex App",
"codexAuth.appLogin": "App login",
"codexAuth.accountPool": "Account Pool",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index 7a83c8935b..d2ee008a70 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -1329,6 +1329,7 @@ export const ja: Record = {
"integrations.semantics.kimi": "再起動するか /reload を実行すると適用されます(v2 はファイルを監視します)。",
"integrations.semantics.gajae": "新しいセッション、または /model を開いたときに適用されます。",
"codexAuth.mainAccount": "メインアカウント",
+ "codexAuth.logLabel": "ログラベル",
"codexAuth.codexApp": "Codex App",
"codexAuth.appLogin": "アプリログイン",
"codexAuth.accountPool": "アカウントプール",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index 4820c45f00..7a5fa9b4af 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -943,6 +943,7 @@ export const ko: Record = {
"integrations.semantics.kimi": "재시작 또는 /reload 시 적용됩니다 (v2는 파일 변경을 감지합니다).",
"integrations.semantics.gajae": "새 세션 또는 /model을 열 때 적용됩니다.",
"codexAuth.mainAccount": "메인 계정",
+ "codexAuth.logLabel": "로그 라벨",
"codexAuth.codexApp": "Codex App",
"codexAuth.appLogin": "앱 로그인",
"codexAuth.accountPool": "계정 풀",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 9370936f15..c4df1a933c 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -1371,6 +1371,7 @@ export const ru: Record = {
"integrations.semantics.kimi": "Чтобы применить, перезапустите клиент или выполните /reload (v2 отслеживает файл).",
"integrations.semantics.gajae": "Применяется в новом сеансе или при открытии /model.",
"codexAuth.mainAccount": "Основной аккаунт",
+ "codexAuth.logLabel": "Метка журнала",
"codexAuth.codexApp": "Codex App",
"codexAuth.appLogin": "Вход через приложение",
"codexAuth.accountPool": "Пул аккаунтов",
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index 89f790833e..de51ee0b64 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -1378,6 +1378,7 @@ export const tr: Record = {
"integrations.semantics.gajae": "Yeni oturuma uygulanır.",
"integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.",
"codexAuth.mainAccount": "Ana Hesap",
+ "codexAuth.logLabel": "Günlük etiketi",
"codexAuth.codexApp": "Codex Uygulaması",
"codexAuth.appLogin": "Uygulama girişi",
"codexAuth.accountPool": "Hesap Havuzu",
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index a4f36917df..16e99c0273 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -1032,6 +1032,7 @@ export const zhTW: Record = {
"nav.closeMenu": "關閉選單",
"codexAuth.mainAccount": "主帳號",
"codexAuth.codexApp": "Codex App",
+ "codexAuth.logLabel": "日誌標籤",
"codexAuth.appLogin": "應用登入",
"codexAuth.accountPool": "帳號池",
"codexAuth.accountModeTitle": "OpenAI 帳號模式",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 953de29cac..792a0029ca 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -936,6 +936,7 @@ export const zh: Record = {
"integrations.semantics.kimi": "重启或运行 /reload 以应用(v2 会监视该文件)。",
"integrations.semantics.gajae": "在新会话中或打开 /model 时生效。",
"codexAuth.mainAccount": "主账号",
+ "codexAuth.logLabel": "日志标签",
"codexAuth.codexApp": "Codex App",
"codexAuth.appLogin": "应用登录",
"codexAuth.accountPool": "账号池",
diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx
index 2f41d45627..2d8e42037a 100644
--- a/gui/tests/codex-account-pool-behaviour.test.tsx
+++ b/gui/tests/codex-account-pool-behaviour.test.tsx
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act } from "react";
import type { Root } from "react-dom/client";
+import { clearClientResourceStoresForTests } from "../src/client-resource";
import { useCodexAccountPool, type CodexAccountPoolController } from "../src/hooks/useCodexAccountPool";
/**
@@ -21,6 +22,7 @@ let root: Root | null = null;
let calls: string[] = [];
let originalFetch: typeof globalThis.fetch;
let accounts: unknown[] = [];
+let usageAccounts: unknown[] = [];
let threshold = 80;
let nextAccountsResponseGate: Promise | null = null;
let pauseResponseActiveId: string | null = null;
@@ -65,11 +67,15 @@ beforeEach(() => {
activeGetId = null;
deleteCatalogRefreshPending = false;
accounts = [{ id: "a1", email: "account-one", isMain: true, paused: false, priority: 0, hasCredential: true, quota: null }];
+ usageAccounts = [];
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: async (url: string, init?: RequestInit) => {
const path = String(url).split("/api/")[1] ?? String(url);
calls.push(`${init?.method ?? "GET"} ${path}`);
+ if (path.startsWith("usage?")) {
+ return { ok: true, json: async () => ({ accounts: usageAccounts }) } as unknown as Response;
+ }
if (path === "codex-auth/accounts/priority") {
const gate = nextPriorityResponseGate;
nextPriorityResponseGate = null;
@@ -177,6 +183,7 @@ afterEach(async () => {
root = null;
}
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
+ clearClientResourceStoresForTests();
for (const key of globals) {
Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] });
}
@@ -211,6 +218,37 @@ test("the controller loads once on mount", async () => {
expect(seen.current!.loadState).toBe("ready");
});
+test("the controller joins 30-day usage to accounts by the displayed log label", async () => {
+ accounts = [
+ { id: "main", email: "main", isMain: true, paused: false, priority: 0, hasCredential: true, quota: null },
+ { id: "pool", email: "pool", logLabel: "pabc123", isMain: false, paused: false, priority: 0, hasCredential: true, quota: null },
+ ];
+ usageAccounts = [
+ { accountLogLabel: "main", totalTokens: 11, estimatedCostUsd: 0.01, usageCoverageRatio: 1 },
+ { accountLogLabel: "pabc123", totalTokens: 22, estimatedCostUsd: 0.02, usageCoverageRatio: 0.5 },
+ { accountLogLabel: "legacy-ambiguous", totalTokens: 999, estimatedCostUsd: 9, usageCoverageRatio: 1 },
+ ];
+
+ const seen = await mountController();
+ expect(seen.current!.accounts.map(account => ({
+ label: account.logLabel,
+ tokens: account.usage30d?.totalTokens,
+ }))).toEqual([
+ { label: "main", tokens: 11 },
+ { label: "pabc123", tokens: 22 },
+ ]);
+});
+
+test("account reloads do not refetch the independently polled usage summary", async () => {
+ const seen = await mountController();
+ expect(calls.filter(call => call.startsWith("GET usage?")).length).toBe(1);
+
+ await act(async () => { await seen.current!.load(); });
+
+ expect(calls.filter(call => call.startsWith("GET usage?")).length).toBe(1);
+ expect(calls.filter(call => call.includes("codex-auth/accounts")).length).toBe(2);
+});
+
test("an inert controller issues no requests at all", async () => {
await mountController(false);
expect(calls.length).toBe(0);
diff --git a/gui/tests/codex-account-pool-pinned-badge.test.tsx b/gui/tests/codex-account-pool-pinned-badge.test.tsx
index f31d5e4191..d562492530 100644
--- a/gui/tests/codex-account-pool-pinned-badge.test.tsx
+++ b/gui/tests/codex-account-pool-pinned-badge.test.tsx
@@ -27,11 +27,17 @@ let originalFetch: typeof globalThis.fetch;
const account: CodexAccountEntry = {
id: "pool-1",
email: "pool@example.test",
+ logLabel: "pabc123",
isMain: false,
paused: false,
priority: 0,
hasCredential: true,
quota: null,
+ usage30d: {
+ totalTokens: 1_500,
+ estimatedCostUsd: 0.125,
+ usageCoverageRatio: 0.75,
+ },
};
const mainAccount: CodexAccountEntry = {
@@ -199,3 +205,16 @@ test("a paused account is never shown as pinned", async () => {
expect(hasPinnedBadge(host)).toBe(false);
expect(hasPinnedHint(host)).toBe(false);
});
+
+test("account cards show the same log label and 30-day usage identity", async () => {
+ await mountPool(makeController());
+
+ const pooled = cardFor("pool@example.test");
+ expect(pooled.textContent).toContain("Log label: pabc123");
+ expect(pooled.textContent).toContain("Total tokens: 1.5k");
+ expect(pooled.textContent).toContain("Estimated cost: ~$0.1250");
+ expect(pooled.textContent).toContain("Measured: 75%");
+
+ const main = cardFor("main@example.test");
+ expect(main.textContent).toContain("Log label: main");
+});
diff --git a/src/codex/account-label.ts b/src/codex/account-label.ts
index 4c935354bd..cbbd8cf2f8 100644
--- a/src/codex/account-label.ts
+++ b/src/codex/account-label.ts
@@ -1,5 +1,7 @@
import { createHash, randomBytes } from "node:crypto";
-import type { CodexAccount } from "../types";
+import type { CodexAccount, OcxConfig } from "../types";
+import type { CodexAuthContext } from "./auth-context";
+import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/;
@@ -22,6 +24,17 @@ export function codexAccountLogLabel(account: CodexAccount): string {
: fallbackCodexAccountLogLabel(account.id);
}
+/** Effective durable label for a resolved Codex Pool account. */
+export function codexAuthContextLogLabel(
+ authCtx: CodexAuthContext,
+ config: Pick,
+): "main" | `p${string}` | undefined {
+ if (authCtx.kind !== "pool" && authCtx.kind !== "main-pool") return undefined;
+ if (authCtx.accountId === MAIN_CODEX_ACCOUNT_ID) return "main";
+ const account = (config.codexAccounts ?? []).find(candidate => candidate.id === authCtx.accountId);
+ return account ? codexAccountLogLabel(account) as `p${string}` : undefined;
+}
+
export function withCodexAccountLogLabel(
account: Omit & Partial>,
existingAccounts: readonly CodexAccount[],
diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts
index 145971352a..87c849c638 100644
--- a/src/codex/auth-api.ts
+++ b/src/codex/auth-api.ts
@@ -5,7 +5,7 @@ import {
saveConfigPreservingClaudeCode,
withConfigMutationLockSync,
} from "../config";
-import { withCodexAccountLogLabel } from "./account-label";
+import { codexAccountLogLabel, withCodexAccountLogLabel } from "./account-label";
import {
getCodexAccountCredential,
getValidCodexToken,
@@ -239,7 +239,7 @@ function poolAccountDto(
email: maskEmail(account.email) ?? account.email,
...(account.alias !== undefined ? { alias: account.alias } : {}),
...(plan !== undefined ? { plan } : {}),
- ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
+ logLabel: codexAccountLogLabel(account),
isMain: false,
paused,
priority,
@@ -1228,6 +1228,7 @@ export async function listCodexAuthAccountsSnapshot(
id: MAIN_CODEX_ACCOUNT_ID,
email: maskEmail(mainInfo.email) ?? "Codex App login",
plan: mainInfo.plan,
+ logLabel: "main",
isMain: true,
paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts
index 50a66ac908..5d5cd8bbe1 100644
--- a/src/server/management/logs-usage-routes.ts
+++ b/src/server/management/logs-usage-routes.ts
@@ -261,6 +261,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise undefined);
outcomeCtx = alternate.authCtx;
+ logCtx.accountLogLabel = codexAuthContextLogLabel(alternate.authCtx, config);
try {
upstream = await sendCompactAttempt(alternate.provider, alternate.headers, "single");
} catch (err) {
diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts
index 5de97affde..6e7bde1efe 100644
--- a/src/server/responses/core.ts
+++ b/src/server/responses/core.ts
@@ -95,6 +95,7 @@ import {
recordCodexUpstreamOutcome,
type CodexUpstreamOutcome,
} from "../../codex/routing";
+import { codexAuthContextLogLabel } from "../../codex/account-label";
import {
applyUpstreamRecoveryInit,
fetchWithResetRetry,
@@ -495,6 +496,13 @@ async function retryCodexPoolOnAlternateAccount(
retryAuthCtx.accountId,
config,
);
+ logCtx.accountLogLabel = codexAuthContextLogLabel(retryAuthCtx, config);
+ sealRequestAttemptIdentity(
+ logCtx.activeAttempt,
+ logCtx.provider,
+ retryAdapter.name,
+ logCtx.accountLogLabel,
+ );
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
let upstreamResponse: Response;
@@ -1170,6 +1178,7 @@ export async function handleComboResponses(
attempt,
childLog.provider,
childLog.providerAdapter ?? attempt.adapter,
+ childLog.accountLogLabel,
);
finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage);
(logCtx.attempts ??= []).push(attempt);
@@ -1224,6 +1233,7 @@ export async function handleComboResponses(
attempt,
childLog.provider,
childLog.providerAdapter ?? attempt.adapter,
+ childLog.accountLogLabel,
);
(logCtx.attempts ??= []).push(attempt);
attemptRetained = true;
@@ -1269,6 +1279,7 @@ export async function handleComboResponses(
attempt,
childLog.provider,
childLog.providerAdapter ?? attempt.adapter,
+ childLog.accountLogLabel,
);
finishRequestAttempt(
attempt,
@@ -1666,6 +1677,7 @@ async function handleResponsesInner(
logCtx.provider = route.codexAccountNamespace
? `${route.providerName}-${route.codexAccountNamespace}`
: formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
+ logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config);
// Prefer Codex pool account as the Cursor thread namespace when present. Cursor routes without
// codexAccountMode still get a credential-derived scope inside the Cursor adapter.
const identityScope = codexLogAccountId(authCtx);
@@ -1763,6 +1775,7 @@ async function handleResponsesInner(
delete route.codexAccountId;
delete route.codexAccountNamespace;
logCtx.provider = route.providerName;
+ delete logCtx.accountLogLabel;
}
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
logCtx.providerAdapter = adapter.name;
@@ -1779,7 +1792,7 @@ async function handleResponsesInner(
logCtx.activeAttemptStartedAt = Date.now();
(logCtx.attempts ??= []).push(attempt);
}
- sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name);
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name, logCtx.accountLogLabel);
// CL-09: attach only the opaque exact route-subject identity to the attempt.
// This is best-effort passive metadata: no Lab state is created and failure
// must never alter, retry, or delay the upstream request.
@@ -3077,7 +3090,7 @@ async function handleResponsesInner(
: undefined;
if (retryEstimate !== undefined) logCtx.usageLogInputTokens = retryEstimate;
logCtx.providerAdapter = activeAdapter.name;
- sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
try {
try {
@@ -3229,7 +3242,7 @@ async function handleResponsesInner(
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
config.cacheRetention,
);
- sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
const result = await rebuildAndRefetch("anthropic-oauth-429");
if ("failed" in result) return result.failed;
upstreamResponse = result;
@@ -3497,7 +3510,7 @@ async function handleResponsesInner(
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
config.cacheRetention,
);
- sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
nextContinuationRecoveryKind = "anthropic-oauth-429";
continue;
} catch {
diff --git a/src/usage/log.ts b/src/usage/log.ts
index 00b750ad03..5256c2af93 100644
--- a/src/usage/log.ts
+++ b/src/usage/log.ts
@@ -5,8 +5,14 @@ import { recordOwnedConfigPath } from "../lib/config-ownership";
import { usageDisplayTotalTokens } from "./totals";
import type { OcxUsage } from "../types";
import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace";
+import { CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
export type UsageStatus = "reported" | "unreported" | "unsupported" | "estimated";
+export type CodexUsageAccountLogLabel = "main" | `p${string}`;
+
+export function isCodexUsageAccountLogLabel(value: unknown): value is CodexUsageAccountLogLabel {
+ return value === "main" || (typeof value === "string" && CODEX_ACCOUNT_LOG_LABEL_RE.test(value));
+}
/**
* Recovery kinds recorded per attempt in the usage log; the GUI renders localized labels
@@ -33,6 +39,8 @@ export interface PersistedUsageAttempt {
sendCount: number;
recoveryKinds: AttemptRecoveryKind[];
usageStatus: UsageStatus;
+ /** Stable non-PII identity for the Codex pool account that served this attempt. */
+ accountLogLabel?: CodexUsageAccountLogLabel;
inputTokenEstimate?: number;
usage?: OcxUsage;
totalTokens?: number;
@@ -58,6 +66,8 @@ export interface PersistedUsageEntry {
admissionKind?: "configured" | "environment" | "loopback";
/** The inbound wire, not the client product — see `surface`. */
inboundProtocol?: "responses" | "chat" | "messages";
+ /** Stable non-PII identity for Codex Pool usage; absent for Direct/non-Codex traffic. */
+ accountLogLabel?: CodexUsageAccountLogLabel;
/** Best-effort chat/session correlation for Logs grouping (#330). */
conversationId?: string;
resolvedModel?: string;
@@ -271,6 +281,9 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null {
sendCount: attempt.sendCount as number,
recoveryKinds,
usageStatus: attempt.usageStatus as UsageStatus,
+ ...(isCodexUsageAccountLogLabel(attempt.accountLogLabel)
+ ? { accountLogLabel: attempt.accountLogLabel }
+ : {}),
...(isNonNegativeFiniteNumber(attempt.inputTokenEstimate)
? { inputTokenEstimate: attempt.inputTokenEstimate }
: {}),
@@ -350,6 +363,9 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
: {}),
...(isKnownAdmissionKind(entry.admissionKind) ? { admissionKind: entry.admissionKind } : {}),
...(isKnownInboundProtocol(entry.inboundProtocol) ? { inboundProtocol: entry.inboundProtocol } : {}),
+ ...(isCodexUsageAccountLogLabel(entry.accountLogLabel)
+ ? { accountLogLabel: entry.accountLogLabel }
+ : {}),
...(typeof entry.conversationId === "string" && entry.conversationId.trim()
? { conversationId: entry.conversationId.trim().slice(0, 128) }
: {}),
diff --git a/src/usage/summary.ts b/src/usage/summary.ts
index 8337505ed5..65f91be109 100644
--- a/src/usage/summary.ts
+++ b/src/usage/summary.ts
@@ -1,8 +1,8 @@
import { baseProviderLabel } from "../providers/label";
import { canonicalAntigravityUsageModel } from "../providers/antigravity-models";
import { usageDisplayTotalTokens } from "./totals";
-import type { PersistedUsageEntry, UsageStatus } from "./log";
-import { estimateComboCost, estimateRequestCost, serviceTierContext } from "./cost";
+import { isCodexUsageAccountLogLabel, type PersistedUsageEntry, type UsageStatus } from "./log";
+import { estimateAttemptCost, estimateComboCost, estimateRequestCost, serviceTierContext } from "./cost";
export type UsageRange = "7d" | "30d" | "all";
export type UsageSurface = "all" | "codex" | "claude" | "grok";
@@ -79,6 +79,28 @@ export interface UsageProvider {
estimatedCostUsd?: number;
}
+export interface UsageAccount {
+ accountLogLabel: string;
+ ambiguous: boolean;
+ requests: number;
+ attemptCount: number;
+ measuredAttempts: number;
+ reportedAttempts: number;
+ estimatedAttempts: number;
+ unmeteredAttempts: number;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadInputTokens: number;
+ cacheCreationInputTokens: number;
+ reasoningOutputTokens: number;
+ totalTokens: number;
+ usageCoverageRatio: number;
+ estimatedCostUsd?: number;
+ pricedAttempts: number;
+ unpricedAttempts: number;
+ priceCoverageRatio: number;
+}
+
export interface UsageSummary {
range: UsageRange;
surface: UsageSurface;
@@ -88,6 +110,7 @@ export interface UsageSummary {
days: UsageDay[];
models: UsageModel[];
providers: UsageProvider[];
+ accounts: UsageAccount[];
}
const DAY_MS = 86_400_000;
@@ -547,6 +570,133 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us
return providers.sort((a, b) => b.requests - a.requests);
}
+const LEGACY_AMBIGUOUS_ACCOUNT_LABEL = "legacy-ambiguous";
+
+function legacyCodexAccountLabel(provider: string): string | null {
+ if (baseProviderLabel(provider) !== "openai") return null;
+ const suffix = provider.match(/-(main|p[a-f0-9]{6})$/)?.[1];
+ return suffix ?? LEGACY_AMBIGUOUS_ACCOUNT_LABEL;
+}
+
+function accountLabelForAttribution(provider: string, explicit: unknown): string | null {
+ if (isCodexUsageAccountLogLabel(explicit)) return explicit;
+ return legacyCodexAccountLabel(provider);
+}
+
+function buildAccounts(entries: PersistedUsageEntry[]): UsageAccount[] {
+ const byLabel = new Map();
+ const requestIds = new Map>();
+
+ const add = (input: {
+ requestId: string;
+ provider: string;
+ accountLogLabel?: string;
+ usageStatus: UsageStatus;
+ usage?: PersistedUsageEntry["usage"];
+ totalTokens?: number;
+ estimate: ReturnType;
+ }): void => {
+ const label = accountLabelForAttribution(input.provider, input.accountLogLabel);
+ if (!label) return;
+ let row = byLabel.get(label);
+ if (!row) {
+ row = {
+ accountLogLabel: label,
+ ambiguous: label === LEGACY_AMBIGUOUS_ACCOUNT_LABEL,
+ requests: 0,
+ attemptCount: 0,
+ measuredAttempts: 0,
+ reportedAttempts: 0,
+ estimatedAttempts: 0,
+ unmeteredAttempts: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadInputTokens: 0,
+ cacheCreationInputTokens: 0,
+ reasoningOutputTokens: 0,
+ totalTokens: 0,
+ usageCoverageRatio: 0,
+ pricedAttempts: 0,
+ unpricedAttempts: 0,
+ priceCoverageRatio: 0,
+ };
+ byLabel.set(label, row);
+ requestIds.set(label, new Set());
+ }
+ requestIds.get(label)!.add(input.requestId);
+ row.requests = requestIds.get(label)!.size;
+ row.attemptCount += 1;
+ const measured = input.usage !== undefined && isMeasuredStatus(input.usageStatus);
+ if (!measured) {
+ row.unmeteredAttempts += 1;
+ return;
+ }
+
+ row.measuredAttempts += 1;
+ if (input.usageStatus === "reported") row.reportedAttempts += 1;
+ else if (input.usageStatus === "estimated") row.estimatedAttempts += 1;
+ row.inputTokens += input.usage!.inputTokens;
+ row.outputTokens += input.usage!.outputTokens;
+ const creation = input.usage!.cacheCreationInputTokens;
+ const read = typeof input.usage!.cacheReadInputTokens === "number"
+ ? input.usage!.cacheReadInputTokens
+ : typeof input.usage!.cachedInputTokens === "number" && typeof creation === "number"
+ ? Math.max(0, input.usage!.cachedInputTokens - creation)
+ : input.usage!.cachedInputTokens;
+ if (typeof read === "number") row.cacheReadInputTokens += read;
+ if (typeof creation === "number") row.cacheCreationInputTokens += creation;
+ if (typeof input.usage!.reasoningOutputTokens === "number") {
+ row.reasoningOutputTokens += input.usage!.reasoningOutputTokens;
+ }
+ row.totalTokens += usageDisplayTotalTokens(input.usage, input.totalTokens) ?? 0;
+ if (input.estimate) {
+ row.pricedAttempts += 1;
+ row.estimatedCostUsd = (row.estimatedCostUsd ?? 0) + input.estimate.cost.total;
+ } else {
+ row.unpricedAttempts += 1;
+ }
+ };
+
+ for (const entry of entries) {
+ const tier = serviceTierContext(entry);
+ if (entry.attempts?.length) {
+ for (const attempt of entry.attempts) {
+ add({
+ requestId: entry.requestId,
+ provider: attempt.provider,
+ ...(attempt.accountLogLabel ? { accountLogLabel: attempt.accountLogLabel } : {}),
+ usageStatus: attempt.usageStatus,
+ ...(attempt.usage ? { usage: attempt.usage } : {}),
+ ...(attempt.totalTokens !== undefined ? { totalTokens: attempt.totalTokens } : {}),
+ estimate: estimateAttemptCost(attempt, undefined, tier),
+ });
+ }
+ continue;
+ }
+ add({
+ requestId: entry.requestId,
+ provider: entry.provider,
+ ...(entry.accountLogLabel ? { accountLogLabel: entry.accountLogLabel } : {}),
+ usageStatus: entry.usageStatus,
+ ...(entry.usage ? { usage: entry.usage } : {}),
+ ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
+ estimate: estimateRequestCost({
+ provider: entry.provider,
+ model: entry.model,
+ usage: entry.usage,
+ usageStatus: entry.usageStatus,
+ serviceTier: tier,
+ }),
+ });
+ }
+
+ for (const row of byLabel.values()) {
+ row.usageCoverageRatio = row.attemptCount === 0 ? 0 : row.measuredAttempts / row.attemptCount;
+ row.priceCoverageRatio = row.measuredAttempts === 0 ? 0 : row.pricedAttempts / row.measuredAttempts;
+ }
+ return [...byLabel.values()].sort((a, b) => b.totalTokens - a.totalTokens);
+}
+
export function summarizeUsage(
entries: PersistedUsageEntry[],
range: UsageRange,
@@ -581,5 +731,6 @@ export function summarizeUsage(
days: buildDayGrid(range, since, now, filteredEntries),
models: buildModels(filteredEntries, totals.totalTokens),
providers: buildProviders(filteredEntries, totals.totalTokens),
+ accounts: buildAccounts(filteredEntries),
};
}
diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md
index d9cbea71d4..5867321f0e 100644
--- a/structure/05_gui-and-management-api.md
+++ b/structure/05_gui-and-management-api.md
@@ -293,6 +293,10 @@ keeps the saved state and renders fixed `ocx sync` guidance without server/accou
`src/usage/log.ts` writes append-only JSONL to `~/.opencodex/usage.jsonl` with file mode `0o600`.
`src/usage/summary.ts` turns that file into the `/api/usage` shape — totals, daily zero-filled
grid, model and provider breakdowns, and `measured / reported / unreported / unsupported / estimated` counts.
+A Codex-surface response also includes an `accounts` breakdown keyed by the stable non-PII
+`accountLogLabel`; current cards join those rows to the management account DTO and show the 30-day
+token total, API-equivalent cost estimate, and measurement coverage. New main-pool rows use `main`,
+while legacy bare `openai` rows stay ambiguous rather than being reassigned from current config.
A missing `usage.jsonl` returns a zeroed summary with 200, not an error: a fresh install has no
usage and must not render as a failure. What the shape must never do is present an unmeasured
request as a measured zero — that is what the `measured / reported / unreported / unsupported /
diff --git a/tests/api-usage.test.ts b/tests/api-usage.test.ts
index 33e12d0869..bc6a98f376 100644
--- a/tests/api-usage.test.ts
+++ b/tests/api-usage.test.ts
@@ -92,7 +92,7 @@ afterEach(() => {
});
describe("GET /api/usage", () => {
- test("returns documented shape with summary, days, models, providers", async () => {
+ test("returns documented shape with summary, days, models, providers, and accounts", async () => {
writeFixture(Date.now());
const server = startServer(0);
try {
@@ -105,10 +105,12 @@ describe("GET /api/usage", () => {
expect(body).toHaveProperty("days");
expect(body).toHaveProperty("models");
expect(body).toHaveProperty("providers");
+ expect(body).toHaveProperty("accounts");
expect(body).toMatchObject({ historyTruncated: false, truncatedPrefixBytes: 0, entriesTruncated: false, entriesDropped: 0 });
expect(Array.isArray(body.days)).toBe(true);
expect(Array.isArray(body.models)).toBe(true);
expect(Array.isArray(body.providers)).toBe(true);
+ expect(Array.isArray(body.accounts)).toBe(true);
} finally {
await server.stop(true);
}
@@ -328,6 +330,7 @@ describe("GET /api/usage", () => {
const body = await res.json();
expect(body.surface).toBe("claude");
expect(body.summary.requests).toBe(0);
+ expect(body.accounts).toEqual([]);
expect(body.error).toBe("read_failed");
} finally {
await server.stop(true);
diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts
index 08e85f4a48..e2d8c00790 100644
--- a/tests/codex-auth-api.test.ts
+++ b/tests/codex-auth-api.test.ts
@@ -8,7 +8,7 @@ import {
getNativeMainProfileRequestCount,
resetLifecycleDrainStateForTests,
} from "../src/server/lifecycle";
-import { CODEX_ACCOUNT_LOG_LABEL_RE } from "../src/codex/account-label";
+import { CODEX_ACCOUNT_LOG_LABEL_RE, fallbackCodexAccountLogLabel } from "../src/codex/account-label";
import {
handleCodexAuthAPI, updateAccountQuota, getAccountQuota,
checkAccountIdCollision, getMainChatgptAccountId,
@@ -978,7 +978,7 @@ describe("codex-auth API", () => {
id: "pool-safe",
email: "p***n@example.test",
plan: "Plus",
- logLabel: "work",
+ logLabel: fallbackCodexAccountLogLabel("pool-safe"),
isMain: false,
hasCredential: true,
});
@@ -987,6 +987,21 @@ describe("codex-auth API", () => {
expect(JSON.stringify(pool)).not.toContain("acct-credential-secret");
});
+ test("GET /api/codex-auth/accounts exposes the effective label for legacy pool and main accounts", async () => {
+ const config = makeConfig();
+ seedPoolAccount(config, { id: "legacy-pool", email: "legacy@example.test" });
+ updateAccountQuota("legacy-pool", 10);
+
+ const req = new Request("http://localhost/api/codex-auth/accounts", { method: "GET" });
+ const resp = await handleCodexAuthAPI(req, new URL(req.url), config);
+ const data = await resp!.json() as { accounts: CodexAuthAccountDto[] };
+
+ expect(data.accounts.find(account => account.id === "legacy-pool")?.logLabel)
+ .toBe(fallbackCodexAccountLogLabel("legacy-pool"));
+ expect(data.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)?.logLabel).toBe("main");
+ expect(config.codexAccounts?.[0]?.logLabel).toBeUndefined();
+ });
+
test("POST /api/codex-auth/accounts disables manual import by default before writing credentials", async () => {
const req = new Request("http://localhost/api/codex-auth/accounts", {
method: "POST",
diff --git a/tests/request-log.test.ts b/tests/request-log.test.ts
index 347724dcc4..5425c52a1e 100644
--- a/tests/request-log.test.ts
+++ b/tests/request-log.test.ts
@@ -263,7 +263,7 @@ describe("request log metadata", () => {
noteAttemptSend(a, 100);
noteAttemptSend(a, 120, "transient-5xx");
noteAttemptSend(a, 120, "transient-5xx");
- sealRequestAttemptIdentity(a, "chatgpt-pabcdef", "openai-responses");
+ sealRequestAttemptIdentity(a, "chatgpt-pabcdef", "openai-responses", "pabcdef");
finishRequestAttempt(a, 503, 12);
const b = beginRequestAttempt(2, "prov-b", "model-b", "openai-chat");
@@ -278,6 +278,7 @@ describe("request log metadata", () => {
expect(a).toMatchObject({
ordinal: 1,
provider: "chatgpt-pabcdef",
+ accountLogLabel: "pabcdef",
adapter: "openai-responses",
status: 503,
sendCount: 3,
diff --git a/tests/responses-account-label.test.ts b/tests/responses-account-label.test.ts
new file mode 100644
index 0000000000..ebb0f76636
--- /dev/null
+++ b/tests/responses-account-label.test.ts
@@ -0,0 +1,147 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fallbackCodexAccountLogLabel } from "../src/codex/account-label";
+import { saveCodexAccountCredential } from "../src/codex/account-store";
+import { clearAccountQuota, updateAccountQuota } from "../src/codex/auth-api";
+import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account";
+import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../src/codex/routing";
+import type { RequestLogContext } from "../src/server/request-log";
+import { handleResponses } from "../src/server/responses";
+import type { OcxConfig } from "../src/types";
+
+const originalFetch = globalThis.fetch;
+
+function poolConfig(accountIds: string[]): OcxConfig {
+ return {
+ defaultProvider: "openai",
+ activeCodexAccountId: accountIds[0],
+ autoSwitchThreshold: 0,
+ providers: {
+ openai: {
+ adapter: "openai-responses",
+ baseUrl: "https://chatgpt.com/backend-api/codex",
+ authMode: "forward",
+ codexAccountMode: "pool",
+ },
+ },
+ codexAccounts: accountIds.map(id => ({
+ id,
+ email: `${id}@example.test`,
+ isMain: false,
+ chatgptAccountId: `${id}_chatgpt`,
+ })),
+ } as OcxConfig;
+}
+
+function completedResponse(id: string): Response {
+ return Response.json({
+ id,
+ status: "completed",
+ output: [],
+ usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
+ });
+}
+
+function request(): Request {
+ return new Request("http://localhost/v1/responses", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "gpt-5.6-sol", input: "hello", stream: false }),
+ });
+}
+
+function savePoolCredential(id: string): void {
+ saveCodexAccountCredential(id, {
+ accessToken: `${id}-access-token`,
+ refreshToken: `${id}-refresh-token`,
+ expiresAt: Date.now() + 300_000,
+ chatgptAccountId: `${id}_chatgpt`,
+ });
+}
+
+async function withPoolHome(run: (home: string) => Promise): Promise {
+ const home = mkdtempSync(join(tmpdir(), "ocx-responses-account-label-"));
+ const previousOpencodexHome = process.env.OPENCODEX_HOME;
+ const previousCodexHome = process.env.CODEX_HOME;
+ process.env.OPENCODEX_HOME = home;
+ process.env.CODEX_HOME = home;
+ clearCodexUpstreamHealth();
+ clearThreadAccountMap();
+ clearAccountQuota();
+ try {
+ return await run(home);
+ } finally {
+ globalThis.fetch = originalFetch;
+ clearCodexUpstreamHealth();
+ clearThreadAccountMap();
+ clearAccountQuota();
+ rmSync(home, { recursive: true, force: true });
+ if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME;
+ else process.env.OPENCODEX_HOME = previousOpencodexHome;
+ if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
+ else process.env.CODEX_HOME = previousCodexHome;
+ }
+}
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+});
+
+describe("Responses account usage attribution", () => {
+ test("main-pool and legacy added accounts carry their effective labels", async () => {
+ await withPoolHome(async home => {
+ writeFileSync(join(home, "auth.json"), JSON.stringify({
+ tokens: { access_token: "main-access-token", account_id: "main-account" },
+ }));
+ updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 0);
+ globalThis.fetch = (async () => completedResponse("main-response")) as typeof fetch;
+
+ const mainConfig = poolConfig([]);
+ mainConfig.activeCodexAccountId = MAIN_CODEX_ACCOUNT_ID;
+ const mainLog: RequestLogContext = { model: "", provider: "" };
+ expect((await handleResponses(request(), mainConfig, mainLog, {})).status).toBe(200);
+ expect(mainLog.accountLogLabel).toBe("main");
+ expect(mainLog.activeAttempt?.accountLogLabel).toBe("main");
+
+ const poolConfigValue = poolConfig(["pool-a"]);
+ savePoolCredential("pool-a");
+ updateAccountQuota("pool-a", 0);
+ const poolLog: RequestLogContext = { model: "", provider: "" };
+ expect((await handleResponses(request(), poolConfigValue, poolLog, {})).status).toBe(200);
+ expect(poolLog.accountLogLabel).toBe(fallbackCodexAccountLogLabel("pool-a"));
+ expect(poolLog.activeAttempt?.accountLogLabel).toBe(fallbackCodexAccountLogLabel("pool-a"));
+ });
+ });
+
+ test("a pre-stream quota retry updates attribution to the serving alternate account", async () => {
+ await withPoolHome(async () => {
+ const config = poolConfig(["pool-a", "pool-b"]);
+ for (const id of ["pool-a", "pool-b"]) {
+ savePoolCredential(id);
+ updateAccountQuota(id, id === "pool-a" ? 10 : 20);
+ }
+ const bearers: string[] = [];
+ globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ const bearer = new Headers(init?.headers).get("authorization") ?? "";
+ bearers.push(bearer);
+ if (bearers.length === 1) {
+ return Response.json({ error: { message: "rate limited" } }, {
+ status: 429,
+ headers: { "retry-after": "42" },
+ });
+ }
+ return completedResponse("pool-b-response");
+ }) as typeof fetch;
+
+ const logCtx: RequestLogContext = { model: "", provider: "" };
+ const response = await handleResponses(request(), config, logCtx, {});
+
+ expect(response.status).toBe(200);
+ expect(bearers).toEqual(["Bearer pool-a-access-token", "Bearer pool-b-access-token"]);
+ expect(logCtx.accountLogLabel).toBe(fallbackCodexAccountLogLabel("pool-b"));
+ expect(logCtx.activeAttempt?.accountLogLabel).toBe(fallbackCodexAccountLogLabel("pool-b"));
+ });
+ });
+});
diff --git a/tests/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts
index fdb1830517..98a40b4e15 100644
--- a/tests/responses-compaction-routing.test.ts
+++ b/tests/responses-compaction-routing.test.ts
@@ -20,6 +20,7 @@ import {
} from "../src/codex/routing";
import { clearAccountQuota, updateAccountQuota } from "../src/codex/auth-api";
import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account";
+import { fallbackCodexAccountLogLabel } from "../src/codex/account-label";
import * as authContextModule from "../src/codex/auth-context";
import {
releaseCodexAuthContextProbeLease,
@@ -195,6 +196,47 @@ describe("native compact usage reporting", () => {
expect(body.usage).toMatchObject({ input_tokens: 10, output_tokens: 5, total_tokens: 15 });
expect(logCtx.usage).toMatchObject({ inputTokens: 10, outputTokens: 5, totalTokens: 15 });
});
+
+ test("main-pool and legacy added accounts carry their effective usage labels", async () => {
+ const testDir = mkdtempSync(join(tmpdir(), "ocx-compact-account-label-"));
+ const previousOpencodexHome = process.env.OPENCODEX_HOME;
+ const previousCodexHome = process.env.CODEX_HOME;
+ process.env.OPENCODEX_HOME = testDir;
+ process.env.CODEX_HOME = testDir;
+ try {
+ const mainConfig = nativePoolConfig();
+ mainConfig.codexAccounts = [];
+ mainConfig.activeCodexAccountId = MAIN_CODEX_ACCOUNT_ID;
+ writeFileSync(join(testDir, "auth.json"), JSON.stringify({
+ tokens: { access_token: "main-access-token", account_id: "main-account" },
+ }));
+ updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 0);
+ globalThis.fetch = (async () => jsonResponse(completedPayload("main compact"))) as typeof fetch;
+ const mainLog: RequestLogContext = { model: "", provider: "" };
+ expect((await handleResponsesCompact(compactionRequest(baseCompactionBody({})), mainConfig, mainLog)).status).toBe(200);
+ expect(mainLog.accountLogLabel).toBe("main");
+
+ const poolConfig = nativePoolConfig();
+ saveCodexAccountCredential("pool-a", {
+ accessToken: "pool-access-token",
+ refreshToken: "pool-refresh-token",
+ expiresAt: Date.now() + 300_000,
+ chatgptAccountId: "pool_acc",
+ });
+ updateAccountQuota("pool-a", 0);
+ const poolLog: RequestLogContext = { model: "", provider: "" };
+ expect((await handleResponsesCompact(compactionRequest(baseCompactionBody({})), poolConfig, poolLog)).status).toBe(200);
+ expect(poolLog.accountLogLabel).toBe(fallbackCodexAccountLogLabel("pool-a"));
+ } finally {
+ globalThis.fetch = originalFetch;
+ clearAccountQuota();
+ rmSync(testDir, { recursive: true, force: true });
+ if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME;
+ else process.env.OPENCODEX_HOME = previousOpencodexHome;
+ if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
+ else process.env.CODEX_HOME = previousCodexHome;
+ }
+ });
});
describe("native Codex pool compaction", () => {
@@ -642,15 +684,17 @@ describe("compact alternate-account attempt (#913)", () => {
return jsonResponse(completedPayload("alternate compact response"));
}) as typeof fetch;
+ const logCtx: RequestLogContext = { model: "", provider: "" };
const res = await handleResponsesCompact(
compactionRequest(baseCompactionBody({})),
config,
- { model: "", provider: "" },
+ logCtx,
);
// Two sends, not one and not three: the alternate ran once and did not recurse.
expect(bearers).toEqual(["Bearer pool-a-access-token", "Bearer pool-b-access-token"]);
expect(res.status).toBe(200);
+ expect(logCtx.accountLogLabel).toBe(fallbackCodexAccountLogLabel("pool-b"));
});
});
diff --git a/tests/usage-log.test.ts b/tests/usage-log.test.ts
index 98302aca91..5a4b905043 100644
--- a/tests/usage-log.test.ts
+++ b/tests/usage-log.test.ts
@@ -53,6 +53,41 @@ describe("usage log", () => {
expect(normalized.attempts).toEqual([]);
});
+ test("preserves only valid non-PII Codex account log labels", () => {
+ const normalized = normalizeUsageEntryForTest({
+ requestId: "ocx-account-label",
+ timestamp: 1,
+ provider: "openai-pabc123",
+ model: "gpt-test",
+ accountLogLabel: "pabc123",
+ status: 200,
+ durationMs: 1,
+ usageStatus: "reported",
+ attempts: [{
+ ordinal: 1,
+ provider: "openai-pabc123",
+ model: "gpt-test",
+ adapter: "openai-responses",
+ accountLogLabel: "pabc123",
+ status: 200,
+ durationMs: 1,
+ sendCount: 1,
+ recoveryKinds: [],
+ usageStatus: "reported",
+ }],
+ });
+ expect(normalized.accountLogLabel).toBe("pabc123");
+ expect(normalized.attempts?.[0]?.accountLogLabel).toBe("pabc123");
+
+ const rejected = normalizeUsageEntryForTest({
+ ...normalized,
+ accountLogLabel: "raw-account-id",
+ attempts: [{ ...normalized.attempts![0]!, accountLogLabel: "person@example.test" }],
+ });
+ expect(rejected.accountLogLabel).toBeUndefined();
+ expect(rejected.attempts?.[0]?.accountLogLabel).toBeUndefined();
+ });
+
test("persists the rate-limit-429 recovery kind on attempts", () => {
const entry: PersistedUsageEntry = {
requestId: "ocx-ratelimit-kind",
diff --git a/tests/usage-summary.test.ts b/tests/usage-summary.test.ts
index 2d32106d44..52db5bb0ae 100644
--- a/tests/usage-summary.test.ts
+++ b/tests/usage-summary.test.ts
@@ -15,6 +15,7 @@ function entry(overrides: Partial & { ts: number }): Persis
durationMs: rest.durationMs ?? 10,
usageStatus: rest.usageStatus ?? "unreported",
...(rest.surface === "claude" ? { surface: rest.surface } : {}),
+ ...(rest.accountLogLabel !== undefined ? { accountLogLabel: rest.accountLogLabel } : {}),
...(rest.resolvedModel !== undefined ? { resolvedModel: rest.resolvedModel } : {}),
...(rest.usage ? { usage: rest.usage } : {}),
...(rest.totalTokens !== undefined ? { totalTokens: rest.totalTokens } : {}),
@@ -151,6 +152,153 @@ describe("summarizeUsage", () => {
expect(sum.summary.outputTokens).toBe(5);
});
+ test("attributes Codex usage and API-equivalent cost by stable account log label", () => {
+ const entries: PersistedUsageEntry[] = [
+ entry({
+ ts: FIXED_NOW - 1_000,
+ requestId: "added-explicit",
+ provider: "openai-pabc123",
+ accountLogLabel: "pabc123",
+ usageStatus: "reported",
+ usage: { inputTokens: 100, outputTokens: 10 },
+ totalTokens: 110,
+ }),
+ entry({
+ ts: FIXED_NOW - 2_000,
+ requestId: "added-legacy",
+ provider: "openai-pabc123",
+ usageStatus: "estimated",
+ usage: { inputTokens: 40, outputTokens: 5, estimated: true },
+ totalTokens: 45,
+ }),
+ entry({
+ ts: FIXED_NOW - 3_000,
+ requestId: "main-explicit",
+ provider: "openai",
+ accountLogLabel: "main",
+ usageStatus: "reported",
+ usage: { inputTokens: 20, outputTokens: 2 },
+ totalTokens: 22,
+ }),
+ entry({
+ ts: FIXED_NOW - 4_000,
+ requestId: "main-legacy",
+ provider: "openai-main",
+ usageStatus: "reported",
+ usage: { inputTokens: 30, outputTokens: 3 },
+ totalTokens: 33,
+ }),
+ entry({
+ ts: FIXED_NOW - 5_000,
+ requestId: "legacy-bare",
+ provider: "openai",
+ usageStatus: "unreported",
+ }),
+ entry({
+ ts: FIXED_NOW - 6_000,
+ requestId: "custom-selector-explicit",
+ provider: "openai-side",
+ accountLogLabel: "pffffff",
+ usageStatus: "reported",
+ usage: { inputTokens: 500, outputTokens: 50 },
+ totalTokens: 550,
+ }),
+ ];
+
+ const sum = summarizeUsage(entries, "30d", FIXED_NOW, "codex");
+ expect(sum.accounts.map(row => row.accountLogLabel).sort()).toEqual([
+ "legacy-ambiguous",
+ "main",
+ "pabc123",
+ "pffffff",
+ ]);
+ expect(sum.accounts.find(row => row.accountLogLabel === "pabc123")).toMatchObject({
+ requests: 2,
+ attemptCount: 2,
+ measuredAttempts: 2,
+ reportedAttempts: 1,
+ estimatedAttempts: 1,
+ totalTokens: 155,
+ inputTokens: 140,
+ outputTokens: 15,
+ usageCoverageRatio: 1,
+ priceCoverageRatio: 1,
+ });
+ expect(sum.accounts.find(row => row.accountLogLabel === "pabc123")?.estimatedCostUsd)
+ .toBeCloseTo((140 * 5 + 15 * 30) / 1e6, 9);
+ expect(sum.accounts.find(row => row.accountLogLabel === "main")).toMatchObject({
+ requests: 2,
+ totalTokens: 55,
+ ambiguous: false,
+ });
+ expect(sum.accounts.find(row => row.accountLogLabel === "pffffff")).toMatchObject({
+ requests: 1,
+ totalTokens: 550,
+ ambiguous: false,
+ });
+ expect(sum.accounts.find(row => row.accountLogLabel === "legacy-ambiguous")).toMatchObject({
+ requests: 1,
+ unmeteredAttempts: 1,
+ totalTokens: 0,
+ usageCoverageRatio: 0,
+ ambiguous: true,
+ });
+ });
+
+ test("attributes combo attempts to the account that physically served each attempt", () => {
+ const combo = entry({
+ ts: FIXED_NOW - 1_000,
+ requestId: "combo-two-accounts",
+ provider: "combo",
+ model: "combo/native",
+ usageStatus: "reported",
+ usage: { inputTokens: 33, outputTokens: 3 },
+ totalTokens: 36,
+ attempts: [
+ {
+ ordinal: 1,
+ provider: "openai-p111111",
+ model: "gpt-5.5",
+ adapter: "openai-responses",
+ accountLogLabel: "p111111",
+ status: 502,
+ durationMs: 10,
+ sendCount: 1,
+ recoveryKinds: [],
+ usageStatus: "reported",
+ usage: { inputTokens: 11, outputTokens: 1 },
+ totalTokens: 12,
+ },
+ {
+ ordinal: 2,
+ provider: "openai-p222222",
+ model: "gpt-5.5",
+ adapter: "openai-responses",
+ accountLogLabel: "p222222",
+ status: 200,
+ durationMs: 20,
+ sendCount: 1,
+ recoveryKinds: [],
+ usageStatus: "reported",
+ usage: { inputTokens: 22, outputTokens: 2 },
+ totalTokens: 24,
+ },
+ ],
+ });
+
+ const rows = summarizeUsage([combo], "30d", FIXED_NOW, "codex").accounts;
+ expect(rows.find(row => row.accountLogLabel === "p111111")).toMatchObject({
+ requests: 1,
+ attemptCount: 1,
+ totalTokens: 12,
+ });
+ expect(rows.find(row => row.accountLogLabel === "p222222")).toMatchObject({
+ requests: 1,
+ attemptCount: 1,
+ totalTokens: 24,
+ });
+ });
+
test("three OpenAI API Pro selections stay separate from their resolved base models", () => {
const entries = ["sol", "terra", "luna"].map((family, index) => entry({
ts: FIXED_NOW - index * 1000,