Skip to content
Merged
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion docs-site/src/content/docs/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion gui/src/components/codex-account-pool-cards.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,6 +15,7 @@ import {
oauthHealthShowsDoctor,
oauthHealthShowsReauth,
} from "../oauth-health-display";
import { formatCostUsd, formatTokenCount } from "../provider-workspace/usage";

export function CodexAccountPoolCards({
pool,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -144,6 +146,14 @@ export function CodexAccountPoolCards({
</button>
</div>
<div className="card-sub">{a.email}{a.plan ? ` · ${a.plan}` : ""} · {t("prov.accountId")}: {displayAccountId(a.id)}</div>
{a.logLabel && (
<div className="card-sub faint">{t("codexAuth.logLabel")}: <code>{a.logLabel}</code></div>
)}
{a.usage30d && (
<div className="card-sub faint" title={t("logs.metric.estimatedCostTitle")}>
{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)}%
</div>
)}
{healthSummary && (
<div className="card-sub faint">{healthSummary}</div>
)}
Expand Down
9 changes: 9 additions & 0 deletions gui/src/components/codex-account-pool-main-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,6 +17,7 @@ import {
oauthHealthShowsDoctor,
oauthHealthShowsReauth,
} from "../oauth-health-display";
import { formatCostUsd, formatTokenCount } from "../provider-workspace/usage";

export function CodexAccountPoolMainCard({
t,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -135,6 +138,12 @@ export function CodexAccountPoolMainCard({
<span className="card-right"><IconLock width={14} /> {t("codexAuth.appLogin")}</span>
</div>
<div className="card-sub">{main?.email || t("codexAuth.appLogin")}{main?.plan ? ` · ${main.plan}` : ""}</div>
<div className="card-sub faint">{t("codexAuth.logLabel")}: <code>{main?.logLabel ?? "main"}</code></div>
{main?.usage30d && (
<div className="card-sub faint" title={t("logs.metric.estimatedCostTitle")}>
{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)}%
</div>
)}
{healthSummary && (
<div className="card-sub faint">{healthSummary}</div>
)}
Expand Down
56 changes: 50 additions & 6 deletions gui/src/hooks/useCodexAccountPool.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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";
Expand Down Expand Up @@ -104,13 +112,35 @@ 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<string, { accounts: CodexAccountEntry[]; activeId: string | null }>();

export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccountPoolController {
const seed = lastGoodByBase.get(apiBase);
const [accounts, setAccounts] = useState<CodexAccountEntry[]>(() => seed?.accounts ?? []);
const usage30d = useKeyedClientResource<CodexAccountUsageSummary>(
`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<CodexAccountUsageSummary>;
},
{ enabled, pollMs: USAGE_REFRESH_INTERVAL_MS },
);
const [activeId, setActiveId] = useState<string | null>(() => seed?.activeId ?? null);
const [loadState, setLoadState] = useState<CodexAccountLoadState>(() => (seed != null ? "ready" : "loading"));
const [switchingId, setSwitchingId] = useState<string | null>(null);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,7 @@ export const de: Record<TKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,7 @@ export const ja: Record<TKey, string> = {
"integrations.semantics.kimi": "再起動するか /reload を実行すると適用されます(v2 はファイルを監視します)。",
"integrations.semantics.gajae": "新しいセッション、または /model を開いたときに適用されます。",
"codexAuth.mainAccount": "メインアカウント",
"codexAuth.logLabel": "ログラベル",
"codexAuth.codexApp": "Codex App",
"codexAuth.appLogin": "アプリログイン",
"codexAuth.accountPool": "アカウントプール",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,7 @@ export const ko: Record<TKey, string> = {
"integrations.semantics.kimi": "재시작 또는 /reload 시 적용됩니다 (v2는 파일 변경을 감지합니다).",
"integrations.semantics.gajae": "새 세션 또는 /model을 열 때 적용됩니다.",
"codexAuth.mainAccount": "메인 계정",
"codexAuth.logLabel": "로그 라벨",
"codexAuth.codexApp": "Codex App",
"codexAuth.appLogin": "앱 로그인",
"codexAuth.accountPool": "계정 풀",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1371,6 +1371,7 @@ export const ru: Record<TKey, string> = {
"integrations.semantics.kimi": "Чтобы применить, перезапустите клиент или выполните /reload (v2 отслеживает файл).",
"integrations.semantics.gajae": "Применяется в новом сеансе или при открытии /model.",
"codexAuth.mainAccount": "Основной аккаунт",
"codexAuth.logLabel": "Метка журнала",
"codexAuth.codexApp": "Codex App",
"codexAuth.appLogin": "Вход через приложение",
"codexAuth.accountPool": "Пул аккаунтов",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1378,6 +1378,7 @@ export const tr: Record<TKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,7 @@ export const zhTW: Record<TKey, string> = {
"nav.closeMenu": "關閉選單",
"codexAuth.mainAccount": "主帳號",
"codexAuth.codexApp": "Codex App",
"codexAuth.logLabel": "日誌標籤",
"codexAuth.appLogin": "應用登入",
"codexAuth.accountPool": "帳號池",
"codexAuth.accountModeTitle": "OpenAI 帳號模式",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,7 @@ export const zh: Record<TKey, string> = {
"integrations.semantics.kimi": "重启或运行 /reload 以应用(v2 会监视该文件)。",
"integrations.semantics.gajae": "在新会话中或打开 /model 时生效。",
"codexAuth.mainAccount": "主账号",
"codexAuth.logLabel": "日志标签",
"codexAuth.codexApp": "Codex App",
"codexAuth.appLogin": "应用登录",
"codexAuth.accountPool": "账号池",
Expand Down
38 changes: 38 additions & 0 deletions gui/tests/codex-account-pool-behaviour.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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<void> | null = null;
let pauseResponseActiveId: string | null = null;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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] });
}
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions gui/tests/codex-account-pool-pinned-badge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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");
});
Loading
Loading