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,