From 8e1c2c9852679eec6f9472d29b2cb86c12242b8e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 24 Jun 2026 06:45:12 +0900 Subject: [PATCH 01/71] fix: preserve Anthropic stream input usage Cherry-pick PR #23 by @0disoft. Accumulate message_start input usage and message_delta output usage into a single terminal done event on message_stop, with EOF fallback when message_stop is missing. Co-Authored-By: ZeroDi <0disoft@users.noreply.github.com> --- src/adapters/anthropic.ts | 30 +++++++++++++++++++++++------ tests/adapter-usage.test.ts | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 1ecf9e1c35b..420c0206755 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -63,6 +63,14 @@ function usageFromAnthropic(usage: Record | undefined): OcxUsage }; } +function mergeAnthropicUsage( + base: Record | undefined, + next: Record | undefined, +): Record | undefined { + if (!next) return base; + return { ...(base ?? {}), ...next }; +} + function buildToolNameTransforms(provider: OcxProviderConfig): { toWire: (name: string) => string; fromWire: (name: string) => string } { if (provider.authMode === "oauth") { return { toWire: applyClaudeToolPrefix, fromWire: stripClaudeToolPrefix }; @@ -269,6 +277,14 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda let currentBlockType = ""; let currentToolCallId = ""; let currentToolCallName = ""; + let pendingUsage: Record | undefined; + let emittedDone = false; + + const emitDone = function* (): Generator { + if (emittedDone) return; + emittedDone = true; + yield { type: "done", usage: usageFromAnthropic(pendingUsage) }; + }; try { while (true) { @@ -298,6 +314,11 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda } switch (currentEventType || data.type) { + case "message_start": { + const message = data.message as { usage?: Record } | undefined; + pendingUsage = mergeAnthropicUsage(pendingUsage, message?.usage); + break; + } case "content_block_start": { const block = data.content_block as { type: string; id?: string; name?: string } | undefined; if (!block) break; @@ -331,15 +352,11 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda } case "message_delta": { const usage = data.usage as Record | undefined; - if (usage) { - yield { - type: "done", - usage: usageFromAnthropic(usage), - }; - } + pendingUsage = mergeAnthropicUsage(pendingUsage, usage); break; } case "message_stop": { + yield* emitDone(); break; } case "error": { @@ -351,6 +368,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda currentEventType = ""; } } + if (pendingUsage && !emittedDone) yield* emitDone(); } finally { reader.releaseLock(); } diff --git a/tests/adapter-usage.test.ts b/tests/adapter-usage.test.ts index d27d1bc00cc..07c7ea10aeb 100644 --- a/tests/adapter-usage.test.ts +++ b/tests/adapter-usage.test.ts @@ -118,6 +118,44 @@ describe("usage and content retention (F2)", () => { expect(events.at(-1)).toEqual({ type: "done", usage: { inputTokens: 5, outputTokens: 1 } }); }); + test("anthropic stream merges message_start input usage with message_delta output usage", async () => { + const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" }); + const response = new Response([ + 'event: message_start\n', + 'data: {"type":"message_start","message":{"usage":{"input_tokens":20,"cache_read_input_tokens":3,"cache_creation_input_tokens":2}}}\n\n', + 'event: content_block_delta\n', + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n', + 'event: message_delta\n', + 'data: {"type":"message_delta","usage":{"output_tokens":4}}\n\n', + 'event: message_stop\n', + 'data: {"type":"message_stop"}\n\n', + ].join("")); + + const events = []; + for await (const event of adapter.parseStream(response)) events.push(event); + const dones = events.filter(e => e.type === "done"); + expect(events).toContainEqual({ type: "text_delta", text: "hi" }); + expect(dones).toHaveLength(1); + expect(dones[0]).toEqual({ + type: "done", + usage: { inputTokens: 20, outputTokens: 4, cachedInputTokens: 5 }, + }); + }); + + test("anthropic stream emits terminal usage on EOF when message_stop is missing", async () => { + const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" }); + const response = new Response([ + 'event: message_start\n', + 'data: {"type":"message_start","message":{"usage":{"input_tokens":7}}}\n\n', + 'event: message_delta\n', + 'data: {"type":"message_delta","usage":{"output_tokens":1}}\n\n', + ].join("")); + + const events = []; + for await (const event of adapter.parseStream(response)) events.push(event); + expect(events.at(-1)).toEqual({ type: "done", usage: { inputTokens: 7, outputTokens: 1 } }); + }); + test("google emits exactly one done carrying usage", async () => { const adapter = createGoogleAdapter({ ...provider, adapter: "google" }); const response = new Response( From 5ae187e0a993b855ce308b02723a8136736245d3 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 24 Jun 2026 06:45:40 +0900 Subject: [PATCH 02/71] fix: use additive merge for Anthropic stream usage Prevents future key overlap from silently overwriting input usage when both message_start and message_delta contain the same counter. --- src/adapters/anthropic.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 420c0206755..ed2f863da7d 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -68,7 +68,12 @@ function mergeAnthropicUsage( next: Record | undefined, ): Record | undefined { if (!next) return base; - return { ...(base ?? {}), ...next }; + if (!base) return { ...next }; + const merged = { ...base }; + for (const [k, v] of Object.entries(next)) { + merged[k] = (merged[k] ?? 0) + v; + } + return merged; } function buildToolNameTransforms(provider: OcxProviderConfig): { toWire: (name: string) => string; fromWire: (name: string) => string } { From ccbcd6c582d0ae1d0abe887cf57564f227a5c955 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 24 Jun 2026 07:59:29 +0900 Subject: [PATCH 03/71] feat: add CodexAccount types and account credential store Phase 1 of multi-account auth. Adds CodexAccount and CodexAccountCredentials interfaces to types.ts, and a new codex-account-store.ts with CRUD operations + ChatGPT OAuth token refresh following the existing oauth/store.ts pattern. --- src/codex-account-store.ts | 82 ++++++++++++++++++++++++++++++++++++++ src/types.ts | 21 ++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/codex-account-store.ts diff --git a/src/codex-account-store.ts b/src/codex-account-store.ts new file mode 100644 index 00000000000..dac03c85a40 --- /dev/null +++ b/src/codex-account-store.ts @@ -0,0 +1,82 @@ +import { existsSync, readFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir, atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "./config"; +import type { CodexAccountCredentials } from "./types"; + +const CODEX_ACCOUNTS_PATH = join(getConfigDir(), "codex-accounts.json"); +type CodexAccountStore = Record; + +const REFRESH_SKEW_MS = 60_000; + +export function loadCodexAccountStore(): CodexAccountStore { + hardenConfigDir(); + hardenExistingSecret(CODEX_ACCOUNTS_PATH); + if (!existsSync(CODEX_ACCOUNTS_PATH)) return {}; + try { + return JSON.parse(readFileSync(CODEX_ACCOUNTS_PATH, "utf-8")) as CodexAccountStore; + } catch { + return {}; + } +} + +function persist(store: CodexAccountStore): void { + const dir = getConfigDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + atomicWriteFile(CODEX_ACCOUNTS_PATH, JSON.stringify(store, null, 2) + "\n"); +} + +export function getCodexAccountCredential(id: string): CodexAccountCredentials | null { + return loadCodexAccountStore()[id] ?? null; +} + +export function saveCodexAccountCredential(id: string, cred: CodexAccountCredentials): void { + const store = loadCodexAccountStore(); + store[id] = cred; + persist(store); +} + +export function removeCodexAccountCredential(id: string): void { + const store = loadCodexAccountStore(); + delete store[id]; + persist(store); +} + +export function listCodexAccountIds(): string[] { + return Object.keys(loadCodexAccountStore()); +} + +const CHATGPT_TOKEN_URL = "https://auth0.openai.com/oauth/token"; +const CHATGPT_CLIENT_ID = "DRivsnm2Mu42T3KOpqdtwB3NYviHYzwD"; + +export async function getValidCodexToken(id: string): Promise<{ + accessToken: string; + chatgptAccountId: string; +}> { + const cred = getCodexAccountCredential(id); + if (!cred) throw new Error(`Codex account not found: ${id}`); + + if (cred.expiresAt > Date.now() + REFRESH_SKEW_MS) { + return { accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId }; + } + + const res = await fetch(CHATGPT_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + client_id: CHATGPT_CLIENT_ID, + refresh_token: cred.refreshToken, + }), + }); + if (!res.ok) throw new Error(`Token refresh failed for ${id}: ${res.status}`); + const data = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number }; + + const updated: CodexAccountCredentials = { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? cred.refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + chatgptAccountId: cred.chatgptAccountId, + }; + saveCodexAccountCredential(id, updated); + return { accessToken: updated.accessToken, chatgptAccountId: updated.chatgptAccountId }; +} diff --git a/src/types.ts b/src/types.ts index d1b226ee43d..bf2eee4335d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -200,6 +200,12 @@ export interface OcxConfig { webSearchSidecar?: OcxWebSearchSidecarConfig; /** Vision sidecar: describe images via a gpt vision model so text-only models can "see" them. */ visionSidecar?: OcxVisionSidecarConfig; + /** Codex multi-account pool. */ + codexAccounts?: CodexAccount[]; + /** Active pool account id for next session. undefined = main (passthrough as-is). */ + activeCodexAccountId?: string; + /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ + autoSwitchThreshold?: number; } export interface OcxVisionSidecarConfig { @@ -285,3 +291,18 @@ export interface OcxProviderConfig { */ noVisionModels?: string[]; } + +export interface CodexAccount { + id: string; + email: string; + plan?: string; + chatgptAccountId?: string; + isMain: boolean; +} + +export interface CodexAccountCredentials { + accessToken: string; + refreshToken: string; + expiresAt: number; + chatgptAccountId: string; +} From f692f59f7d87d903dd324e1260d80048785c7aa6 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 24 Jun 2026 08:01:51 +0900 Subject: [PATCH 04/71] feat: inject codex account override in passthrough + session affinity Phase 2 of multi-account auth. Passthrough adapter replaces authorization and chatgpt-account-id when _codexAccountOverride is set. WebSocket upgrade path gets the same override via selectForwardHeaders. Session affinity maps thread-id to account-id in memory so mid-session account switches never happen. --- src/adapters/openai-responses.ts | 5 +++++ src/server.ts | 38 +++++++++++++++++++++++++++++++- src/ws-bridge.ts | 9 +++++++- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index ebe85452731..d3acb51a2dd 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -59,6 +59,11 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const v = incoming?.headers.get(h); if (v) headers[h] = v; // …so forwarded auth always wins. } + const override = (provider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string } })._codexAccountOverride; + if (override) { + headers["authorization"] = `Bearer ${override.accessToken}`; + headers["chatgpt-account-id"] = override.chatgptAccountId; + } } else { const base = provider.baseUrl.replace(/\/v1\/?$/, ""); url = `${base}/v1/responses`; diff --git a/src/server.ts b/src/server.ts index e4a7d224ae4..e17c2a44fa4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -31,6 +31,22 @@ import { removeCredential } from "./oauth/store"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "./oauth/key-providers"; import { deriveProviderPresets } from "./providers/derive"; import type { OcxConfig, OcxProviderConfig } from "./types"; +import { getValidCodexToken } from "./codex-account-store"; + +const threadAccountMap = new Map(); + +function resolveCodexAccountForThread( + threadId: string | null, + config: OcxConfig, +): string | null { + if (threadId && threadAccountMap.has(threadId)) { + return threadAccountMap.get(threadId)!; + } + const active = config.activeCodexAccountId; + if (!active) return null; + if (threadId) threadAccountMap.set(threadId, active); + return active; +} // Single source of truth = package.json (../ from src/), so /healthz + the GUI badge match the // installed npm version instead of a stale hardcode. @@ -155,6 +171,20 @@ async function handleResponses( logCtx.model = route.modelId; logCtx.provider = route.providerName; + // Multi-account: if a pool account is active, inject its token for forward-mode passthrough. + if (route.provider.authMode === "forward") { + const threadId = req.headers.get("x-codex-parent-thread-id"); + const accountId = resolveCodexAccountForThread(threadId, config); + if (accountId) { + try { + const override = await getValidCodexToken(accountId); + route.provider = { ...route.provider, _codexAccountOverride: override } as typeof route.provider; + } catch { + // Account token failed — fall back to main passthrough + } + } + } + // OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the // existing openai-chat / anthropic adapters authenticate with no change. if (route.provider.authMode === "oauth") { @@ -739,7 +769,13 @@ export function startServer(port?: number) { if (!isLocalOrigin(req)) { return formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"); } - if (server.upgrade(req, { data: { headers: selectForwardHeaders(req.headers) } })) return undefined as unknown as Response; + const wsThreadId = req.headers.get("x-codex-parent-thread-id"); + const wsAccountId = resolveCodexAccountForThread(wsThreadId, config); + let wsOverride: { accessToken: string; chatgptAccountId: string } | undefined; + if (wsAccountId) { + try { wsOverride = await getValidCodexToken(wsAccountId); } catch { /* fallback to main */ } + } + if (server.upgrade(req, { data: { headers: selectForwardHeaders(req.headers, wsOverride) } })) return undefined as unknown as Response; return formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"); } diff --git a/src/ws-bridge.ts b/src/ws-bridge.ts index 5d69043b785..4159928d89b 100644 --- a/src/ws-bridge.ts +++ b/src/ws-bridge.ts @@ -25,12 +25,19 @@ export class WsSendDroppedError extends Error { } } -export function selectForwardHeaders(headers: Headers): Headers { +export function selectForwardHeaders( + headers: Headers, + codexOverride?: { accessToken: string; chatgptAccountId: string }, +): Headers { const selected = new Headers(); for (const name of FORWARD_HEADERS) { const value = headers.get(name); if (value) selected.set(name, value); } + if (codexOverride) { + selected.set("authorization", `Bearer ${codexOverride.accessToken}`); + selected.set("chatgpt-account-id", codexOverride.chatgptAccountId); + } return selected; } From 19bd067e6145116e83753bc53a3e9d327942e943 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 24 Jun 2026 08:08:55 +0900 Subject: [PATCH 05/71] feat: add codex-auth management API endpoints Phase 3 of multi-account auth. Adds /api/codex-auth/* REST endpoints for account CRUD, active account selection, auto-switch threshold, and quota tracking. Extracted into codex-auth-api.ts to keep server.ts under control. Server delegates via dynamic import. --- src/codex-auth-api.ts | 117 ++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 5 ++ 2 files changed, 122 insertions(+) create mode 100644 src/codex-auth-api.ts diff --git a/src/codex-auth-api.ts b/src/codex-auth-api.ts new file mode 100644 index 00000000000..36d0a1184d3 --- /dev/null +++ b/src/codex-auth-api.ts @@ -0,0 +1,117 @@ +import { loadConfig, saveConfig } from "./config"; +import { + getCodexAccountCredential, + saveCodexAccountCredential, + removeCodexAccountCredential, +} from "./codex-account-store"; +import type { OcxConfig } from "./types"; + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const accountQuota = new Map(); + +export function updateAccountQuota(accountId: string, weekly: number, fiveHour: number): void { + accountQuota.set(accountId, { weeklyPercent: weekly, fiveHourPercent: fiveHour, updatedAt: Date.now() }); +} + +export function getAccountQuota(accountId: string) { + return accountQuota.get(accountId) ?? null; +} + +export async function handleCodexAuthAPI( + req: Request, + url: URL, + _config: OcxConfig, +): Promise { + + if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") { + const config = loadConfig(); + const accounts = config.codexAccounts ?? []; + const withQuota = accounts.map(a => ({ + ...a, + quota: getAccountQuota(a.id), + hasCredential: !!getCodexAccountCredential(a.id), + })); + return jsonResponse({ accounts: withQuota }); + } + + if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") { + const body = (await req.json()) as { + id: string; + email: string; + plan?: string; + accessToken: string; + refreshToken: string; + chatgptAccountId: string; + }; + if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) { + return jsonResponse({ error: "Missing required fields" }, 400); + } + saveCodexAccountCredential(body.id, { + accessToken: body.accessToken, + refreshToken: body.refreshToken, + expiresAt: Date.now() + 3600_000, + chatgptAccountId: body.chatgptAccountId, + }); + const config = loadConfig(); + const accounts = config.codexAccounts ?? []; + if (!accounts.find(a => a.id === body.id)) { + accounts.push({ id: body.id, email: body.email, plan: body.plan, isMain: false }); + config.codexAccounts = accounts; + saveConfig(config); + } + return jsonResponse({ ok: true }); + } + + if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") { + const id = url.searchParams.get("id"); + if (!id) return jsonResponse({ error: "Missing id" }, 400); + removeCodexAccountCredential(id); + const config = loadConfig(); + config.codexAccounts = (config.codexAccounts ?? []).filter(a => a.id !== id); + if (config.activeCodexAccountId === id) config.activeCodexAccountId = undefined; + saveConfig(config); + return jsonResponse({ ok: true }); + } + + if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") { + const body = (await req.json()) as { accountId: string | null }; + const config = loadConfig(); + config.activeCodexAccountId = body.accountId ?? undefined; + saveConfig(config); + return jsonResponse({ ok: true, activeCodexAccountId: body.accountId }); + } + + if (url.pathname === "/api/codex-auth/active" && req.method === "GET") { + const config = loadConfig(); + return jsonResponse({ + activeCodexAccountId: config.activeCodexAccountId ?? null, + autoSwitchThreshold: config.autoSwitchThreshold ?? 80, + }); + } + + if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") { + const body = (await req.json()) as { threshold: number }; + const config = loadConfig(); + config.autoSwitchThreshold = body.threshold; + saveConfig(config); + return jsonResponse({ ok: true }); + } + + if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") { + const quotas: Record = {}; + for (const [id, q] of accountQuota) quotas[id] = q; + return jsonResponse({ quotas }); + } + + return null; +} diff --git a/src/server.ts b/src/server.ts index e17c2a44fa4..c792e7d49df 100644 --- a/src/server.ts +++ b/src/server.ts @@ -724,6 +724,11 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P return jsonResponse({ success: true, message: "Proxy stopping, native Codex restored." }); } + if (url.pathname.startsWith("/api/codex-auth/")) { + const { handleCodexAuthAPI } = await import("./codex-auth-api"); + return handleCodexAuthAPI(req, url, config); + } + return null; } From c2ea8970b43384d168d7bafd1f9d2a8aff8ca55e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 24 Jun 2026 08:14:37 +0900 Subject: [PATCH 06/71] feat: add Codex Auth dashboard tab with account pool management Phase 4 of multi-account auth. Adds CodexAuth page to dashboard sidebar with main account display, pool account list, switch confirmation modal with cache warning, auto-switch toggle, and quota progress bars. i18n keys added for en/ko/zh. --- gui/src/App.tsx | 7 +- gui/src/i18n/en.ts | 21 +++++ gui/src/i18n/ko.ts | 21 +++++ gui/src/i18n/zh.ts | 21 +++++ gui/src/pages/CodexAuth.tsx | 168 ++++++++++++++++++++++++++++++++++++ 5 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 gui/src/pages/CodexAuth.tsx diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 76271b391b8..f15fc5d8dab 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -4,10 +4,11 @@ import Providers from "./pages/Providers"; import Models from "./pages/Models"; import Subagents from "./pages/Subagents"; import Logs from "./pages/Logs"; -import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconGithub, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower } from "./icons"; +import CodexAuth from "./pages/CodexAuth"; +import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconKey, IconGithub, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower } from "./icons"; import { useI18n, useT, LOCALES, type TKey } from "./i18n"; -type Page = "dashboard" | "providers" | "models" | "subagents" | "logs"; +type Page = "dashboard" | "providers" | "models" | "subagents" | "logs" | "codex-auth"; type Theme = "light" | "dark" | "system"; const API_BASE = import.meta.env.VITE_API_BASE || ""; @@ -19,6 +20,7 @@ const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [ { id: "models", tkey: "nav.models", Icon: IconBoxes }, { id: "subagents", tkey: "nav.subagents", Icon: IconBot }, { id: "logs", tkey: "nav.logs", Icon: IconList }, + { id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey }, ]; const THEME_ICON = { light: IconSun, dark: IconMoon, system: IconMonitor } as const; @@ -126,6 +128,7 @@ export default function App() { {page === "models" && } {page === "subagents" && } {page === "logs" && } + {page === "codex-auth" && } diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 71293f60e78..cd2d697ef43 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -135,6 +135,27 @@ export const en = { "modal.waitingLogin": "Waiting for browser login…", "modal.loggingIn": "Logging in…", "modal.loginTimeout": "Login timed out — try again.", + "nav.codexAuth": "Codex Auth", + "codexAuth.mainAccount": "Main Account", + "codexAuth.appLogin": "App login", + "codexAuth.accountPool": "Account Pool", + "codexAuth.add": "Add", + "codexAuth.noPool": "No pool accounts added yet.", + "codexAuth.weekly": "Weekly", + "codexAuth.fiveHour": "5h Window", + "codexAuth.current": "CURRENT", + "codexAuth.nextSession": "NEXT SESSION", + "codexAuth.switchTitle": "Switch active account?", + "codexAuth.switchDesc": "This applies from the next Codex session. Current session stays on the existing account.", + "codexAuth.cacheWarning": "Prompt cache resets on account switch. New session starts with empty cache.", + "codexAuth.setAsNext": "Set as Next Session", + "codexAuth.cancel": "Cancel", + "codexAuth.switchBack": "Switch back to Main?", + "codexAuth.switchBackDesc": "Next Codex session will use your App login account again.", + "codexAuth.autoSwitch": "Auto-switch at 80%", + "codexAuth.autoSwitchDesc": "New sessions auto-pick lowest usage account", + "codexAuth.switched": "{email} will be used from next session", + "codexAuth.removeConfirm": "Remove {id}?", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3c67b646f29..d5a8a3f738f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -135,4 +135,25 @@ export const ko: Record = { "modal.waitingLogin": "브라우저 로그인 대기 중…", "modal.loggingIn": "로그인 중…", "modal.loginTimeout": "로그인 시간 초과 — 다시 시도하세요.", + "nav.codexAuth": "Codex 인증", + "codexAuth.mainAccount": "메인 계정", + "codexAuth.appLogin": "앱 로그인", + "codexAuth.accountPool": "계정 풀", + "codexAuth.add": "추가", + "codexAuth.noPool": "풀 계정이 아직 없습니다.", + "codexAuth.weekly": "주간", + "codexAuth.fiveHour": "5시간", + "codexAuth.current": "현재", + "codexAuth.nextSession": "다음 세션", + "codexAuth.switchTitle": "활성 계정을 변경하시겠습니까?", + "codexAuth.switchDesc": "다음 Codex 세션부터 적용됩니다. 현재 세션은 기존 계정을 유지합니다.", + "codexAuth.cacheWarning": "계정 전환 시 프롬프트 캐시가 초기화됩니다.", + "codexAuth.setAsNext": "다음 세션으로 설정", + "codexAuth.cancel": "취소", + "codexAuth.switchBack": "메인 계정으로 돌아가시겠습니까?", + "codexAuth.switchBackDesc": "다음 Codex 세션에서 앱 로그인 계정을 사용합니다.", + "codexAuth.autoSwitch": "80% 초과 시 자동 전환", + "codexAuth.autoSwitchDesc": "새 세션에서 사용량이 가장 적은 계정을 자동 선택", + "codexAuth.switched": "다음 세션부터 {email}을(를) 사용합니다", + "codexAuth.removeConfirm": "{id}을(를) 삭제하시겠습니까?", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 13dc8769a4b..adcffd7217d 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -135,4 +135,25 @@ export const zh: Record = { "modal.waitingLogin": "等待浏览器登录…", "modal.loggingIn": "登录中…", "modal.loginTimeout": "登录超时 — 请重试。", + "nav.codexAuth": "Codex 认证", + "codexAuth.mainAccount": "主账号", + "codexAuth.appLogin": "应用登录", + "codexAuth.accountPool": "账号池", + "codexAuth.add": "添加", + "codexAuth.noPool": "尚未添加池账号。", + "codexAuth.weekly": "每周", + "codexAuth.fiveHour": "5小时", + "codexAuth.current": "当前", + "codexAuth.nextSession": "下次会话", + "codexAuth.switchTitle": "切换活跃账号?", + "codexAuth.switchDesc": "从下次 Codex 会话开始生效。当前会话保持现有账号。", + "codexAuth.cacheWarning": "切换账号会重置提示缓存。新会话从空缓存开始。", + "codexAuth.setAsNext": "设为下次会话", + "codexAuth.cancel": "取消", + "codexAuth.switchBack": "切换回主账号?", + "codexAuth.switchBackDesc": "下次 Codex 会话将使用应用登录账号。", + "codexAuth.autoSwitch": "超过 80% 时自动切换", + "codexAuth.autoSwitchDesc": "新会话自动选择使用量最低的账号", + "codexAuth.switched": "下次会话将使用 {email}", + "codexAuth.removeConfirm": "删除 {id}?", }; diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx new file mode 100644 index 00000000000..4a617d22e3c --- /dev/null +++ b/gui/src/pages/CodexAuth.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from "react"; +import { useT, type TFn } from "../i18n"; +import { IconLock, IconPlus, IconX, IconAlert } from "../icons"; +import { Notice } from "../ui"; + +interface AccountQuota { weeklyPercent: number; fiveHourPercent: number; updatedAt: number } +interface AccountEntry { + id: string; email: string; plan?: string; isMain: boolean; + hasCredential: boolean; quota: AccountQuota | null; +} + +export default function CodexAuth({ apiBase }: { apiBase: string }) { + const t = useT(); + const [accounts, setAccounts] = useState([]); + const [activeId, setActiveId] = useState(null); + const [autoThreshold, setAutoThreshold] = useState(80); + const [confirm, setConfirm] = useState(null); + const [toast, setToast] = useState(""); + + const load = async () => { + try { + const [accts, active] = await Promise.all([ + fetch(`${apiBase}/api/codex-auth/accounts`).then(r => r.json()), + fetch(`${apiBase}/api/codex-auth/active`).then(r => r.json()), + ]); + setAccounts(accts.accounts ?? []); + setActiveId(active.activeCodexAccountId ?? null); + setAutoThreshold(active.autoSwitchThreshold ?? 80); + } catch { /* ignore */ } + }; + useEffect(() => { load(); const iv = setInterval(load, 30_000); return () => clearInterval(iv); }, [apiBase]); + + const setActive = async (id: string | null) => { + await fetch(`${apiBase}/api/codex-auth/active`, { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: id }), + }); + setActiveId(id); + setConfirm(null); + const label = id ? accounts.find(a => a.id === id)?.email ?? id : "main"; + setToast(t("codexAuth.switched", { email: label })); + setTimeout(() => setToast(""), 5000); + }; + + const remove = async (id: string) => { + if (!window.confirm(t("codexAuth.removeConfirm", { id }))) return; + await fetch(`${apiBase}/api/codex-auth/accounts?id=${id}`, { method: "DELETE" }); + load(); + }; + + const toggleAuto = async () => { + const next = autoThreshold > 0 ? 0 : 80; + await fetch(`${apiBase}/api/codex-auth/auto-switch`, { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ threshold: next }), + }); + setAutoThreshold(next); + }; + + const main = accounts.find(a => a.isMain); + const pool = accounts.filter(a => !a.isMain); + const isNext = (id: string) => activeId === id; + + return ( +
+

{t("nav.codexAuth")}

+ + {toast && {toast}} + + {main && ( +
activeId ? setConfirm({ ...main, id: "__main__" }) : undefined} + style={{ cursor: activeId ? "pointer" : "default" }}> +
+ + {t("codexAuth.mainAccount")} + + {!activeId ? t("codexAuth.nextSession") : t("codexAuth.current")} + + {t("codexAuth.appLogin")} +
+
{main.email}{main.plan ? ` · ${main.plan}` : ""}
+ +
+ )} + +
+ {t("codexAuth.accountPool")} +
+ +
+ + {pool.length === 0 &&

{t("codexAuth.noPool")}

} + + {pool.map(a => ( +
setConfirm(a)} style={{ cursor: "pointer" }}> +
+ + {a.email} + {a.plan && {a.plan}} + {isNext(a.id) && {t("codexAuth.nextSession")}} + +
+ +
+ ))} + +
+
+ {t("codexAuth.autoSwitch")} +
{t("codexAuth.autoSwitchDesc")}
+
+ +
+ + {confirm && ( +
setConfirm(null)}> +
e.stopPropagation()}> +

{confirm.id === "__main__" ? t("codexAuth.switchBack") : t("codexAuth.switchTitle")}

+

+ {confirm.id === "__main__" ? t("codexAuth.switchBackDesc") : t("codexAuth.switchDesc")} +

+
+ {confirm.id === "__main__" ? main?.email : confirm.email} + {confirm.plan && {confirm.plan}} +
+ {confirm.id !== "__main__" && ( +
{t("codexAuth.cacheWarning")}
+ )} +
+ + +
+
+
+ )} +
+ ); +} + +function QuotaBars({ quota, t }: { quota: AccountQuota | null; t: TFn }) { + if (!quota) return null; + const wColor = quota.weeklyPercent >= 80 ? "bar-amber" : "bar-green"; + const hColor = quota.fiveHourPercent >= 80 ? "bar-amber" : "bar-green"; + return ( +
+
+ {t("codexAuth.weekly")} +
+ {Math.round(quota.weeklyPercent)}% +
+
+ {t("codexAuth.fiveHour")} +
+ {Math.round(quota.fiveHourPercent)}% +
+
+ ); +} From ce868ad2b300295b363ac3b691ed7f08a59c0067 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 24 Jun 2026 08:17:21 +0900 Subject: [PATCH 07/71] test: add multi-account auth tests for store CRUD and passthrough override Phase 5 of multi-account auth. Tests cover credential save/load/remove, account listing, token caching, and passthrough header override with and without _codexAccountOverride. 9 new tests, 176 total pass. --- tests/codex-account-store.test.ts | 56 ++++++++++++++++++++++++++++++ tests/passthrough-override.test.ts | 39 +++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 tests/codex-account-store.test.ts create mode 100644 tests/passthrough-override.test.ts diff --git a/tests/codex-account-store.test.ts b/tests/codex-account-store.test.ts new file mode 100644 index 00000000000..f67b369cacc --- /dev/null +++ b/tests/codex-account-store.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdirSync, rmSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const TEST_DIR = join(import.meta.dir, ".tmp-codex-accounts-test"); +const ACCOUNTS_PATH = join(TEST_DIR, "codex-accounts.json"); + +describe("codex-account-store CRUD", () => { + beforeEach(() => { + process.env.OPENCODEX_HOME = TEST_DIR; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + delete process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + + test("save and load credential round-trip", async () => { + const { saveCodexAccountCredential, getCodexAccountCredential } = await import("../src/codex-account-store"); + const cred = { accessToken: "tk_a", refreshToken: "rf_a", expiresAt: Date.now() + 3600_000, chatgptAccountId: "acc_a" }; + saveCodexAccountCredential("work", cred); + const loaded = getCodexAccountCredential("work"); + expect(loaded).toEqual(cred); + }); + + test("remove credential deletes entry", async () => { + const { saveCodexAccountCredential, removeCodexAccountCredential, getCodexAccountCredential } = await import("../src/codex-account-store"); + saveCodexAccountCredential("temp", { accessToken: "t", refreshToken: "r", expiresAt: 0, chatgptAccountId: "c" }); + removeCodexAccountCredential("temp"); + expect(getCodexAccountCredential("temp")).toBeNull(); + }); + + test("listCodexAccountIds returns stored ids", async () => { + const { saveCodexAccountCredential, listCodexAccountIds } = await import("../src/codex-account-store"); + saveCodexAccountCredential("a", { accessToken: "1", refreshToken: "1", expiresAt: 0, chatgptAccountId: "1" }); + saveCodexAccountCredential("b", { accessToken: "2", refreshToken: "2", expiresAt: 0, chatgptAccountId: "2" }); + expect(listCodexAccountIds()).toContain("a"); + expect(listCodexAccountIds()).toContain("b"); + }); + + test("getValidCodexToken returns cached token when not expired", async () => { + const { saveCodexAccountCredential, getValidCodexToken } = await import("../src/codex-account-store"); + const future = Date.now() + 3600_000; + saveCodexAccountCredential("fresh", { accessToken: "valid_tk", refreshToken: "rf", expiresAt: future, chatgptAccountId: "acc_id" }); + const result = await getValidCodexToken("fresh"); + expect(result.accessToken).toBe("valid_tk"); + expect(result.chatgptAccountId).toBe("acc_id"); + }); + + test("getValidCodexToken throws when account not found", async () => { + const { getValidCodexToken } = await import("../src/codex-account-store"); + expect(getValidCodexToken("nonexistent")).rejects.toThrow("not found"); + }); +}); diff --git a/tests/passthrough-override.test.ts b/tests/passthrough-override.test.ts new file mode 100644 index 00000000000..3fb67ee09db --- /dev/null +++ b/tests/passthrough-override.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../src/adapters/openai-responses"; +import { selectForwardHeaders } from "../src/ws-bridge"; + +const forwardProvider = { adapter: "openai-responses", baseUrl: "https://chat.openai.com/backend-api/codex", authMode: "forward" as const }; + +describe("passthrough token override", () => { + test("buildRequest uses original auth when no override", () => { + const adapter = createResponsesPassthroughAdapter(forwardProvider); + const headers = new Headers({ authorization: "Bearer original", "chatgpt-account-id": "main_acc" }); + const req = adapter.buildRequest({ modelId: "gpt-5.3", input: [], _rawBody: {} }, { headers }); + expect(req.headers["authorization"]).toBe("Bearer original"); + expect(req.headers["chatgpt-account-id"]).toBe("main_acc"); + }); + + test("buildRequest replaces auth when _codexAccountOverride present", () => { + const override = { accessToken: "pool_token", chatgptAccountId: "pool_acc" }; + const provider = { ...forwardProvider, _codexAccountOverride: override } as typeof forwardProvider; + const adapter = createResponsesPassthroughAdapter(provider); + const headers = new Headers({ authorization: "Bearer original", "chatgpt-account-id": "main_acc" }); + const req = adapter.buildRequest({ modelId: "gpt-5.3", input: [], _rawBody: {} }, { headers }); + expect(req.headers["authorization"]).toBe("Bearer pool_token"); + expect(req.headers["chatgpt-account-id"]).toBe("pool_acc"); + }); + + test("selectForwardHeaders works without override (backward compat)", () => { + const headers = new Headers({ authorization: "Bearer test", "chatgpt-account-id": "acc" }); + const selected = selectForwardHeaders(headers); + expect(selected.get("authorization")).toBe("Bearer test"); + expect(selected.get("chatgpt-account-id")).toBe("acc"); + }); + + test("selectForwardHeaders applies override after forwarding", () => { + const headers = new Headers({ authorization: "Bearer original", "chatgpt-account-id": "main" }); + const selected = selectForwardHeaders(headers, { accessToken: "override_tk", chatgptAccountId: "override_acc" }); + expect(selected.get("authorization")).toBe("Bearer override_tk"); + expect(selected.get("chatgpt-account-id")).toBe("override_acc"); + }); +}); From e379e5863d7a52e3cb431bbbb796c308c4c8811a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 24 Jun 2026 08:23:42 +0900 Subject: [PATCH 08/71] =?UTF-8?q?fix:=20CodexAuth=20UI=20=E2=80=94=20alway?= =?UTF-8?q?s=20show=20main=20account,=20add=20missing=20CSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main account card now renders even when codexAccounts is empty. Added card-head, card-sub, quota-bars, toggle, section-sep, badge-primary, notice-warn, modal-desc, modal-actions CSS classes matching the existing opencodex design system. --- gui/src/pages/CodexAuth.tsx | 30 ++++++++++++++---------------- gui/src/styles.css | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index 4a617d22e3c..995d5536449 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -67,22 +67,20 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { {toast && {toast}} - {main && ( -
activeId ? setConfirm({ ...main, id: "__main__" }) : undefined} - style={{ cursor: activeId ? "pointer" : "default" }}> -
- - {t("codexAuth.mainAccount")} - - {!activeId ? t("codexAuth.nextSession") : t("codexAuth.current")} - - {t("codexAuth.appLogin")} -
-
{main.email}{main.plan ? ` · ${main.plan}` : ""}
- +
activeId ? setConfirm({ id: "__main__", email: main?.email ?? "Codex App", plan: main?.plan, isMain: true, hasCredential: true, quota: main?.quota ?? null }) : undefined} + style={{ cursor: activeId ? "pointer" : "default", marginBottom: 12 }}> +
+ + {t("codexAuth.mainAccount")} + + {!activeId ? t("codexAuth.nextSession") : t("codexAuth.current")} + + {t("codexAuth.appLogin")}
- )} +
{main?.email ?? "Codex App login"}{main?.plan ? ` · ${main.plan}` : ""}
+ {main?.quota && } +
{t("codexAuth.accountPool")} @@ -96,7 +94,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { {pool.map(a => (
setConfirm(a)} style={{ cursor: "pointer" }}> + onClick={() => setConfirm(a)} style={{ cursor: "pointer", marginBottom: 8 }}>
{a.email} diff --git a/gui/src/styles.css b/gui/src/styles.css index 09d0c3a472c..04842a043ac 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -256,6 +256,36 @@ select.input { appearance: none; } .modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .modal-head h3 { font-size: 16px; } +/* ---- codex auth page ---- */ +.card-head { display: flex; align-items: center; gap: 8px; padding: 14px 16px 6px; flex-wrap: wrap; } +.card-sub { font-size: 12px; color: var(--muted); padding: 0 16px 10px; } +.card-active { border-color: var(--accent-ring); } +.card-right { margin-left: auto; display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--faint); } +.card-row { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; } +.badge-primary { background: var(--accent-soft); color: var(--accent-hover); } +.dot-blue { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.dot-muted { background: var(--muted); } +.section-sep { display: flex; align-items: center; gap: 10px; margin: 20px 0 12px; } +.section-label { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; } +.sep-line { flex: 1; height: 1px; background: var(--border); } +.quota-bars { padding: 4px 16px 12px; display: flex; gap: 14px; } +.quota-row { flex: 1; display: flex; align-items: center; gap: 6px; } +.quota-label { font-size: 11px; color: var(--muted); min-width: 40px; } +.quota-val { font-size: 11px; font-family: var(--mono); color: var(--muted); min-width: 28px; text-align: right; } +.bar { flex: 1; height: 5px; background: var(--raised); border-radius: 3px; overflow: hidden; } +.bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } +.bar-green { background: var(--green); } +.bar-amber { background: linear-gradient(90deg, var(--green), var(--amber)); } +.toggle { width: 36px; height: 20px; border-radius: 10px; background: var(--raised); border: 1px solid var(--border); cursor: pointer; position: relative; transition: background 0.15s; flex-shrink: 0; } +.toggle.on { background: var(--accent); border-color: var(--accent); } +.toggle-knob { width: 16px; height: 16px; background: white; border-radius: 50%; position: absolute; top: 1px; left: 1px; transition: left 0.15s; } +.toggle.on .toggle-knob { left: 17px; } +.notice-warn { font-size: 12px; padding: 8px 10px; border-radius: var(--radius-sm); background: var(--amber-soft); color: var(--amber); display: flex; align-items: center; gap: 6px; margin-bottom: 12px; } +.notice-warn svg { width: 14px; height: 14px; flex-shrink: 0; } +.modal-desc { font-size: 13px; color: var(--muted); margin-bottom: 14px; } +.modal-actions { display: flex; gap: 8px; margin-top: 16px; } +.modal-actions .btn { flex: 1; } + .setup-guide { font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 12px; margin-bottom: 4px; } .setup-guide summary { cursor: pointer; color: var(--accent-hover); font-weight: 500; } .setup-guide summary:hover { text-decoration: underline; } From 0f49e43c4343f468e1efb8524a6b6ba84c9661c0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 24 Jun 2026 08:30:14 +0900 Subject: [PATCH 09/71] feat: show real Codex account info in Codex Auth tab Read ~/.codex/auth.json tokens and fetch email, plan, quota from chatgpt.com/backend-api/wham/usage. Main account always shows real data (email, plan type, weekly/5h quota bars) instead of placeholder text. 60s cache to avoid hammering upstream. --- src/codex-auth-api.ts | 68 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/codex-auth-api.ts b/src/codex-auth-api.ts index 36d0a1184d3..3386833356b 100644 --- a/src/codex-auth-api.ts +++ b/src/codex-auth-api.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import os from "node:os"; import { loadConfig, saveConfig } from "./config"; import { getCodexAccountCredential, @@ -27,6 +30,56 @@ export function getAccountQuota(accountId: string) { return accountQuota.get(accountId) ?? null; } +function readCodexTokens(): { access_token: string; account_id: string } | null { + try { + const codexHome = process.env["CODEX_HOME"] || join(os.homedir(), ".codex"); + const authPath = join(codexHome, "auth.json"); + if (!existsSync(authPath)) return null; + const j = JSON.parse(readFileSync(authPath, "utf-8")) as { tokens?: { access_token?: string; account_id?: string } }; + if (j?.tokens?.access_token) return { access_token: j.tokens.access_token, account_id: j.tokens.account_id ?? "" }; + } catch { /* best effort */ } + return null; +} + +let mainAccountCache: { email: string | null; plan: string | null; quota: { weeklyPercent: number; fiveHourPercent: number } | null; ts: number } | null = null; +const MAIN_CACHE_TTL = 60_000; + +async function fetchMainAccountInfo(): Promise<{ email: string | null; plan: string | null; quota: { weeklyPercent: number; fiveHourPercent: number } | null }> { + if (mainAccountCache && Date.now() - mainAccountCache.ts < MAIN_CACHE_TTL) { + return mainAccountCache; + } + const tokens = readCodexTokens(); + if (!tokens) return { email: null, plan: null, quota: null }; + try { + const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { + headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, + signal: AbortSignal.timeout(8000), + }); + if (!resp.ok) return { email: null, plan: null, quota: null }; + const data = (await resp.json()) as { + email?: string | null; + plan_type?: string | null; + rate_limit?: { + primary_window?: { used_percent?: number }; + secondary_window?: { used_percent?: number }; + }; + }; + const result = { + email: data.email ?? null, + plan: data.plan_type ?? null, + quota: data.rate_limit ? { + weeklyPercent: data.rate_limit.secondary_window?.used_percent ?? 0, + fiveHourPercent: data.rate_limit.primary_window?.used_percent ?? 0, + } : null, + ts: Date.now(), + }; + mainAccountCache = result; + return result; + } catch { + return { email: null, plan: null, quota: null }; + } +} + export async function handleCodexAuthAPI( req: Request, url: URL, @@ -35,13 +88,22 @@ export async function handleCodexAuthAPI( if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") { const config = loadConfig(); - const accounts = config.codexAccounts ?? []; - const withQuota = accounts.map(a => ({ + const poolAccounts = (config.codexAccounts ?? []).filter(a => !a.isMain); + const withQuota = poolAccounts.map(a => ({ ...a, quota: getAccountQuota(a.id), hasCredential: !!getCodexAccountCredential(a.id), })); - return jsonResponse({ accounts: withQuota }); + const mainInfo = await fetchMainAccountInfo(); + const main = { + id: "__main__", + email: mainInfo.email ?? "Codex App login", + plan: mainInfo.plan, + isMain: true, + hasCredential: true, + quota: mainInfo.quota ? { ...mainInfo.quota, updatedAt: Date.now() } : null, + }; + return jsonResponse({ accounts: [main, ...withQuota] }); } if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") { From 2efa62965ca67862725afe0632e25c60faf50ea6 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 24 Jun 2026 08:59:00 +0900 Subject: [PATCH 10/71] feat: add account import modal + extend cache TTL to 5 minutes Add button now opens a proper modal with account ID input and auth.json textarea. Validates JSON structure, extracts tokens, and POSTs to /api/codex-auth/accounts. Main account cache TTL changed from 60s to 5 minutes to reduce upstream calls. --- gui/src/components/AddCodexAccountModal.tsx | 121 ++++++++++++++++++++ gui/src/i18n/en.ts | 9 ++ gui/src/i18n/ko.ts | 9 ++ gui/src/i18n/zh.ts | 9 ++ gui/src/pages/CodexAuth.tsx | 12 +- src/codex-auth-api.ts | 2 +- 6 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 gui/src/components/AddCodexAccountModal.tsx diff --git a/gui/src/components/AddCodexAccountModal.tsx b/gui/src/components/AddCodexAccountModal.tsx new file mode 100644 index 00000000000..24a51fd6006 --- /dev/null +++ b/gui/src/components/AddCodexAccountModal.tsx @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from "react"; +import { IconX } from "../icons"; +import { useT } from "../i18n"; + +export default function AddCodexAccountModal({ + apiBase, onClose, onAdded, +}: { + apiBase: string; + onClose: () => void; + onAdded: () => void; +}) { + const t = useT(); + const aliveRef = useRef(true); + useEffect(() => () => { aliveRef.current = false; }, []); + + const [id, setId] = useState(""); + const [json, setJson] = useState(""); + const [error, setError] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const handleImport = async () => { + setError(""); + let parsed: Record; + try { + parsed = JSON.parse(json); + } catch { + setError(t("codexAuth.importInvalidJson")); + return; + } + + const tokens = (parsed.tokens ?? parsed) as Record; + const accessToken = (tokens.access_token ?? tokens.accessToken) as string | undefined; + const refreshToken = (tokens.refresh_token ?? tokens.refreshToken) as string | undefined; + const accountId = (tokens.account_id ?? tokens.accountId ?? "") as string; + + if (!accessToken || !refreshToken) { + setError(t("codexAuth.importMissingTokens")); + return; + } + + if (!id.trim()) { + setError(t("codexAuth.importMissingId")); + return; + } + + setSaving(true); + try { + const resp = await fetch(`${apiBase}/api/codex-auth/accounts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: id.trim(), + email: id.trim(), + accessToken, + refreshToken, + chatgptAccountId: accountId, + }), + }); + if (!resp.ok) { + const body = await resp.json().catch(() => ({})) as { error?: string }; + setError(body.error ?? "Failed"); + return; + } + onAdded(); + onClose(); + } catch (e) { + if (aliveRef.current) setError(String(e)); + } finally { + if (aliveRef.current) setSaving(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

{t("codexAuth.addTitle")}

+ +
+ + + setId(e.target.value)} + style={{ marginBottom: 12 }} + /> + + +