diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0c7a40d9bd9e..007903f29ba6 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,10 @@ import { assert, it } from "@effect/vitest"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + applyPreferredCodexDefaultModel, + codexStoredLoginEmail, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -161,3 +165,18 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); + +it("reads the email claim from a stored ChatGPT login", () => { + const claims = Buffer.from(JSON.stringify({ email: "pooled@example.com" })).toString("base64url"); + const authJson = JSON.stringify({ tokens: { id_token: `header.${claims}.signature` } }); + assert.strictEqual(codexStoredLoginEmail(authJson), "pooled@example.com"); + assert.strictEqual( + codexStoredLoginEmail(JSON.stringify({ OPENAI_API_KEY: "sk-test" })), + undefined, + ); + assert.strictEqual(codexStoredLoginEmail("not json"), undefined); + assert.strictEqual( + codexStoredLoginEmail(JSON.stringify({ tokens: { id_token: "no-payload" } })), + undefined, + ); +}); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index cdf40f73b1bd..912068d13c97 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -1,3 +1,7 @@ +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -72,6 +76,8 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; readonly rateLimits?: CodexRateLimitsProbe; + /** Email of the ChatGPT login in `auth.json`, read only when `account/read` names no account. */ + readonly storedLoginEmail?: string | undefined; readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; @@ -142,6 +148,40 @@ function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) return account.email; } +/** + * The email claim of the ChatGPT login stored in a Codex home's `auth.json`. + * A custom `model_provider` with `requires_openai_auth = false`, such as a + * CLIProxyAPI account pool, makes `account/read` report no account, yet + * `account/rateLimits/read` still answers for this stored login. Without its + * email, Limits cannot tell that those windows belong to an account a + * usage-limit source also reports, and shows the account twice. + */ +export function codexStoredLoginEmail(authJson: string): string | undefined { + try { + const idToken: unknown = JSON.parse(authJson)?.tokens?.id_token; + const payload = typeof idToken === "string" ? idToken.split(".")[1] : undefined; + if (!payload) return undefined; + const email: unknown = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))?.email; + return typeof email === "string" && email.trim() ? email.trim() : undefined; + } catch { + return undefined; + } +} + +const readCodexStoredLoginEmail = (homePath: string | undefined, environment: NodeJS.ProcessEnv) => + Effect.tryPromise(() => + NodeFSP.readFile( + NodePath.join( + homePath || environment.CODEX_HOME || NodePath.join(NodeOS.homedir(), ".codex"), + "auth.json", + ), + "utf8", + ), + ).pipe( + Effect.map(codexStoredLoginEmail), + Effect.orElseSucceed(() => undefined), + ); + export function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, ): ModelCapabilities { @@ -433,7 +473,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models, rateLimits] = yield* Effect.all( + const [skillsResponse, models, rateLimits, storedLoginEmail] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], @@ -459,6 +499,12 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun ), ), ), + accountResponse.account + ? Effect.succeed(undefined) + : readCodexStoredLoginEmail( + input.homePath ? expandHomePath(input.homePath) : undefined, + input.environment ?? process.env, + ), ], { concurrency: "unbounded" }, ); @@ -466,6 +512,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun return { account: accountResponse, rateLimits, + ...(storedLoginEmail ? { storedLoginEmail } : {}), version, models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), @@ -529,13 +576,16 @@ const makePendingCodexProvider = ( }); }); -function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { +function accountProbeStatus( + account: CodexAppServerProviderSnapshot["account"], + storedLoginEmail?: string, +): { readonly status: Exclude; readonly auth: ServerProvider["auth"]; readonly message?: string; } { const authLabel = codexAccountAuthLabel(account.account); - const authEmail = codexAccountEmail(account.account); + const authEmail = account.account ? codexAccountEmail(account.account) : storedLoginEmail; const auth = { status: account.account ? ("authenticated" as const) : ("unknown" as const), ...(account.account?.type ? { type: account.account?.type } : {}), @@ -654,7 +704,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu } const snapshot = probeResult.success.value; - const accountStatus = accountProbeStatus(snapshot.account); + const accountStatus = accountProbeStatus(snapshot.account, snapshot.storedLoginEmail); const usageLimits = snapshot.account.account?.type === "apiKey" ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index caee1981d79f..3c2d8b1b14f4 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -473,6 +473,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("reports the stored login email when a custom provider hides the account", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { account: null, requiresOpenaiAuth: false }, + storedLoginEmail: "pooled@example.com", + }), + ), + ); + + assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual(status.auth.email, "pooled@example.com"); + }), + ); + it.effect("returns an api key label for codex api key auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>