= {
aigateway: 'AI Gateway',
'openai-compatible': 'OpenAI-Compatible',
rowboat: 'Rowboat',
+ // Matches what other subscription clients call this provider; the auth
+ // itself is "Sign in with ChatGPT" (Plus/Pro subscription).
+ codex: 'OpenAI Codex',
}
-type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
+type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" | "codex"
interface ConfiguredModel {
provider: ProviderName
@@ -540,6 +543,12 @@ function ChatInputInner({
groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] })
}
+ // ChatGPT subscription (codex): models:list only carries this catalog
+ // while signed in with ChatGPT, so presence is the gate.
+ if ((catalog['codex'] || []).length > 0) {
+ groups.push({ kind: 'catalog', flavor: 'codex', models: catalog['codex'] })
+ }
+
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data)
@@ -615,6 +624,12 @@ function ChatInputInner({
loadModelConfig()
}, [isActive, loadModelConfig])
+ // ChatGPT subscription models appear/disappear with the ChatGPT session.
+ useEffect(() => {
+ const cleanup = window.ipc.on('chatgpt:statusChanged', () => { loadModelConfig() })
+ return cleanup
+ }, [loadModelConfig])
+
// Reload when model config changes (e.g. from settings dialog)
useEffect(() => {
const handler = () => { loadModelConfig() }
diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx
index 25386aae..cc881247 100644
--- a/apps/x/apps/renderer/src/components/settings-dialog.tsx
+++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx
@@ -32,6 +32,7 @@ import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
import type { ipc as ipcShared } from "@x/shared"
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
import { useProviderModels } from "@/hooks/use-provider-models"
+import { useChatGPT } from "@/hooks/useChatGPT"
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
@@ -554,6 +555,9 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
apiKey: activeConfig.apiKey,
baseURL: activeConfig.baseURL,
})
+ // "Sign in with ChatGPT" subscription state — only rendered on the OpenAI
+ // card; independent of the API-key providerConfigs state above.
+ const chatgpt = useChatGPT()
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway"
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
@@ -1081,6 +1085,53 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
)}
+ {/* ChatGPT subscription — OAuth sign-in, independent of the API key and
+ of models.json (the Codex model client consumes the token via core) */}
+ {provider === "openai" && (
+
+
+ ChatGPT Subscription
+
+ {chatgpt.status.signedIn ? (
+
+
+
+
+ Connected as {chatgpt.status.email ?? "your ChatGPT account"}
+
+
+
+
+ ) : chatgpt.isSigningIn ? (
+
+
+
+ Waiting for browser…
+
+
+
+ ) : (
+
+
+ Use your ChatGPT Plus/Pro subscription
+
+
+
+ )}
+
+ )}
+
{/* Base URL */}
{showBaseURL && (
diff --git a/apps/x/apps/renderer/src/hooks/useChatGPT.ts b/apps/x/apps/renderer/src/hooks/useChatGPT.ts
new file mode 100644
index 00000000..32571d1c
--- /dev/null
+++ b/apps/x/apps/renderer/src/hooks/useChatGPT.ts
@@ -0,0 +1,114 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { toast } from 'sonner';
+
+// "Sign in with ChatGPT" state, modeled on useOAuth.ts but against the
+// dedicated chatgpt:* IPC surface: status is fetched on mount and re-derived
+// from action results (chatgpt:signIn resolves with the final status — no
+// broadcast event to listen for).
+
+type ChatGPTStatus = {
+ signedIn: boolean;
+ email?: string;
+ accountId?: string;
+};
+
+export function useChatGPT() {
+ const [status, setStatus] = useState({ signedIn: false });
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSigningIn, setIsSigningIn] = useState(false);
+ // Cancelled flag + attempt sequence: a cancelled or superseded attempt's
+ // invoke still resolves later (main settles it with `cancelled: true`);
+ // only the CURRENT attempt may touch isSigningIn or show toasts, so a
+ // stale resolution can't clobber a fresh attempt's waiting UI.
+ const cancelledRef = useRef(false);
+ const attemptSeqRef = useRef(0);
+
+ const refresh = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ setStatus(await window.ipc.invoke('chatgpt:getStatus', null));
+ } catch (error) {
+ console.error('Failed to fetch ChatGPT status:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ const signIn = useCallback(async () => {
+ if (isSigningIn) return;
+ const attempt = ++attemptSeqRef.current;
+ cancelledRef.current = false;
+ setIsSigningIn(true);
+ // True only for the attempt whose result should drive the UI.
+ const isCurrent = () => attempt === attemptSeqRef.current && !cancelledRef.current;
+ try {
+ const result = await window.ipc.invoke('chatgpt:signIn', null);
+ if (result.signedIn) {
+ // Always reflect a successful sign-in, even if this attempt was
+ // cancelled client-side after the browser flow completed.
+ setStatus({
+ signedIn: true,
+ ...(result.email ? { email: result.email } : {}),
+ ...(result.accountId ? { accountId: result.accountId } : {}),
+ });
+ if (isCurrent()) {
+ toast.success(result.email ? `Signed in as ${result.email}` : 'Signed in with ChatGPT');
+ }
+ } else if (isCurrent() && !result.cancelled) {
+ toast.error(result.error || 'ChatGPT sign-in failed');
+ }
+ } catch (error) {
+ console.error('ChatGPT sign-in failed:', error);
+ if (isCurrent()) {
+ toast.error('ChatGPT sign-in failed');
+ }
+ } finally {
+ if (isCurrent()) {
+ setIsSigningIn(false);
+ }
+ }
+ }, [isSigningIn]);
+
+ const cancelSignIn = useCallback(() => {
+ cancelledRef.current = true;
+ setIsSigningIn(false);
+ // Really abort the main-process attempt (stops the loopback server and
+ // settles the pending signIn invoke) — otherwise the next Sign In click
+ // would join a dead attempt and never re-open the browser.
+ window.ipc.invoke('chatgpt:cancelSignIn', null).catch((error) => {
+ console.error('Failed to cancel ChatGPT sign-in:', error);
+ });
+ }, []);
+
+ const signOut = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const result = await window.ipc.invoke('chatgpt:signOut', null);
+ if (result.success) {
+ setStatus({ signedIn: false });
+ toast.success('Signed out of ChatGPT');
+ } else {
+ toast.error('Failed to sign out of ChatGPT');
+ }
+ } catch (error) {
+ console.error('ChatGPT sign-out failed:', error);
+ toast.error('Failed to sign out of ChatGPT');
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ return {
+ status,
+ isLoading,
+ isSigningIn,
+ signIn,
+ cancelSignIn,
+ signOut,
+ refresh,
+ };
+}
diff --git a/apps/x/packages/core/src/auth/chatgpt-auth.test.ts b/apps/x/packages/core/src/auth/chatgpt-auth.test.ts
new file mode 100644
index 00000000..a33ed003
--- /dev/null
+++ b/apps/x/packages/core/src/auth/chatgpt-auth.test.ts
@@ -0,0 +1,352 @@
+import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+// Isolate the store from the real home dir BEFORE importing the module —
+// WorkDir is resolved at config.ts import time (same pattern as
+// classification_stamp.test.ts).
+const tmpWorkDir = fs.mkdtempSync(path.join(os.tmpdir(), 'x-chatgpt-auth-test-'));
+process.env.ROWBOAT_WORKDIR = tmpWorkDir;
+
+const chatgptAuth = await import('./chatgpt-auth.js');
+const {
+ saveChatGPTTokens,
+ getChatGPTAccessToken,
+ getChatGPTStatus,
+ signOutChatGPT,
+ setTokenCipher,
+ decodeJwtClaims,
+ ChatGPTAuthRequiredError,
+} = chatgptAuth;
+const {
+ CHATGPT_CLIENT_ID,
+ CHATGPT_TOKEN_URL,
+ CHATGPT_REVOKE_URL,
+ CHATGPT_AUTH_CLAIM_NAMESPACE,
+ CHATGPT_PROFILE_CLAIM_NAMESPACE,
+} = await import('./chatgpt-constants.js');
+
+const AUTH_FILE = path.join(tmpWorkDir, 'config', 'chatgpt-auth.json');
+
+// Fixed clock so expiry math is deterministic.
+const NOW_MS = new Date('2026-07-15T12:00:00Z').getTime();
+const NOW = Math.floor(NOW_MS / 1000);
+
+/** Unsigned JWT with the given payload — decodeJwtClaims only reads part [1]. */
+function makeJwt(claims: Record): string {
+ const b64 = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url');
+ return `${b64({ alg: 'none' })}.${b64(claims)}.sig`;
+}
+
+function makeIdToken(overrides: Record = {}): string {
+ return makeJwt({
+ email: 'user@example.com',
+ [CHATGPT_AUTH_CLAIM_NAMESPACE]: { chatgpt_account_id: 'acct_123' },
+ ...overrides,
+ });
+}
+
+function makeAccessToken(expiresInSeconds: number, extra: Record = {}): string {
+ return makeJwt({ exp: NOW + expiresInSeconds, ...extra });
+}
+
+/** Reversible fake cipher with a toggleable availability flag. */
+const fakeCipher = {
+ available: true,
+ isAvailable() { return this.available; },
+ encrypt(plain: string) { return 'enc:' + Buffer.from(plain).toString('base64'); },
+ decrypt(encrypted: string) {
+ if (!encrypted.startsWith('enc:')) throw new Error('bad ciphertext');
+ return Buffer.from(encrypted.slice(4), 'base64').toString('utf8');
+ },
+};
+
+function readStoredFile(): Record {
+ return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8')) as Record;
+}
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+}
+
+let fetchMock: ReturnType;
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW_MS);
+ fakeCipher.available = true;
+ setTokenCipher(fakeCipher);
+ fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ fs.rmSync(AUTH_FILE, { force: true });
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+afterAll(() => {
+ fs.rmSync(tmpWorkDir, { recursive: true, force: true });
+});
+
+describe('decodeJwtClaims', () => {
+ it('decodes the payload with a pure base64url decode', () => {
+ expect(decodeJwtClaims(makeJwt({ exp: 42, foo: 'bar' }))).toEqual({ exp: 42, foo: 'bar' });
+ });
+
+ it('returns null for malformed input', () => {
+ expect(decodeJwtClaims('not-a-jwt')).toBeNull();
+ expect(decodeJwtClaims('a.!!!.c')).toBeNull();
+ expect(decodeJwtClaims(`a.${Buffer.from('[1,2]').toString('base64url')}.c`)).toBeNull();
+ });
+});
+
+describe('token store', () => {
+ it('persists tokens encrypted at rest and never writes token values in the clear', async () => {
+ const accessToken = makeAccessToken(3600);
+ const identity = await saveChatGPTTokens({
+ accessToken,
+ refreshToken: 'rt_secret_1',
+ idToken: makeIdToken(),
+ });
+
+ expect(identity).toEqual({ accountId: 'acct_123', email: 'user@example.com' });
+
+ const stored = readStoredFile();
+ expect(stored.tokensEncrypted).toMatch(/^enc:/);
+ expect(stored.tokens).toBeUndefined();
+ expect(stored.plaintext).toBeUndefined();
+ expect(stored.expiresAt).toBe(NOW + 3600);
+ // No raw token material anywhere in the file.
+ const raw = fs.readFileSync(AUTH_FILE, 'utf-8');
+ expect(raw).not.toContain(accessToken);
+ expect(raw).not.toContain('rt_secret_1');
+
+ await expect(getChatGPTStatus()).resolves.toEqual({
+ signedIn: true,
+ email: 'user@example.com',
+ accountId: 'acct_123',
+ });
+ });
+
+ it('falls back to plaintext with a marker when the cipher is unavailable', async () => {
+ fakeCipher.available = false;
+ await saveChatGPTTokens({
+ accessToken: makeAccessToken(3600),
+ refreshToken: 'rt_1',
+ idToken: makeIdToken(),
+ });
+
+ const stored = readStoredFile();
+ expect(stored.tokensEncrypted).toBeUndefined();
+ expect(stored.plaintext).toBe(true);
+ expect((stored.tokens as { refreshToken: string }).refreshToken).toBe('rt_1');
+
+ await expect(getChatGPTAccessToken()).resolves.toBe(stored && (stored.tokens as { accessToken: string }).accessToken);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('parses accountId from the access token and email from the profile namespace as fallbacks', async () => {
+ // No id_token; access token carries the auth namespace + profile email.
+ await saveChatGPTTokens({
+ accessToken: makeAccessToken(3600, {
+ [CHATGPT_AUTH_CLAIM_NAMESPACE]: { chatgpt_account_id: 'acct_from_at' },
+ [CHATGPT_PROFILE_CLAIM_NAMESPACE]: { email: 'profile@example.com' },
+ }),
+ refreshToken: 'rt_1',
+ });
+
+ await expect(getChatGPTStatus()).resolves.toEqual({
+ signedIn: true,
+ email: 'profile@example.com',
+ accountId: 'acct_from_at',
+ });
+ });
+
+ it('preserves existing identity when a refresh response carries no claims', async () => {
+ await saveChatGPTTokens({
+ accessToken: makeAccessToken(3600),
+ refreshToken: 'rt_1',
+ idToken: makeIdToken(),
+ });
+ // Rotated tokens with no identity claims at all.
+ await saveChatGPTTokens({
+ accessToken: makeAccessToken(7200),
+ refreshToken: 'rt_2',
+ });
+
+ await expect(getChatGPTStatus()).resolves.toEqual({
+ signedIn: true,
+ email: 'user@example.com',
+ accountId: 'acct_123',
+ });
+ });
+
+ it('reports signed out when nothing is stored', async () => {
+ await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false });
+ await expect(getChatGPTAccessToken()).rejects.toBeInstanceOf(ChatGPTAuthRequiredError);
+ });
+});
+
+describe('getChatGPTAccessToken', () => {
+ it('returns the cached token without refreshing when >5 min from expiry', async () => {
+ const accessToken = makeAccessToken(3600);
+ await saveChatGPTTokens({ accessToken, refreshToken: 'rt_1', idToken: makeIdToken() });
+
+ await expect(getChatGPTAccessToken()).resolves.toBe(accessToken);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('refreshes within the 5-min window using the verified JSON request shape and persists rotation', async () => {
+ await saveChatGPTTokens({
+ accessToken: makeAccessToken(60), // inside the 5-min margin
+ refreshToken: 'rt_old',
+ idToken: makeIdToken(),
+ });
+
+ const newAccessToken = makeAccessToken(3600);
+ fetchMock.mockResolvedValueOnce(jsonResponse({
+ access_token: newAccessToken,
+ refresh_token: 'rt_new',
+ }));
+
+ await expect(getChatGPTAccessToken()).resolves.toBe(newAccessToken);
+
+ // Request shape verified against codex-rs/login/src/auth/manager.rs.
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe(CHATGPT_TOKEN_URL);
+ expect(init.method).toBe('POST');
+ expect((init.headers as Record)['Content-Type']).toBe('application/json');
+ expect(JSON.parse(init.body as string)).toEqual({
+ client_id: CHATGPT_CLIENT_ID,
+ grant_type: 'refresh_token',
+ refresh_token: 'rt_old',
+ });
+
+ // Rotated material persisted (decrypt the file with the fake cipher).
+ const stored = readStoredFile();
+ const material = JSON.parse(fakeCipher.decrypt(stored.tokensEncrypted as string)) as Record;
+ expect(material.accessToken).toBe(newAccessToken);
+ expect(material.refreshToken).toBe('rt_new');
+ expect(stored.expiresAt).toBe(NOW + 3600);
+
+ // Now fresh — no second refresh.
+ await expect(getChatGPTAccessToken()).resolves.toBe(newAccessToken);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps the old refresh token when the response omits one', async () => {
+ await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_keep' });
+ fetchMock.mockResolvedValueOnce(jsonResponse({ access_token: makeAccessToken(3600) }));
+
+ await getChatGPTAccessToken();
+
+ const stored = readStoredFile();
+ const material = JSON.parse(fakeCipher.decrypt(stored.tokensEncrypted as string)) as Record;
+ expect(material.refreshToken).toBe('rt_keep');
+ });
+
+ it('shares a single in-flight refresh across concurrent callers', async () => {
+ await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_1' });
+
+ const newAccessToken = makeAccessToken(3600);
+ let release!: (r: Response) => void;
+ fetchMock.mockReturnValueOnce(new Promise((resolve) => { release = resolve; }));
+
+ const a = getChatGPTAccessToken();
+ const b = getChatGPTAccessToken();
+ // Let both callers reach the refresh gate before releasing the response.
+ await Promise.resolve();
+ release(jsonResponse({ access_token: newAccessToken }));
+
+ await expect(a).resolves.toBe(newAccessToken);
+ await expect(b).resolves.toBe(newAccessToken);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('clears to a clean signed-out state and throws the typed error when the refresh token is rejected', async () => {
+ await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_revoked' });
+ fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'invalid_grant' }, 401));
+
+ await expect(getChatGPTAccessToken()).rejects.toBeInstanceOf(ChatGPTAuthRequiredError);
+ expect(fs.existsSync(AUTH_FILE)).toBe(false);
+ await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false });
+ });
+
+ it('treats 5xx as transient: throws a plain error and keeps the stored tokens', async () => {
+ await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_1', idToken: makeIdToken() });
+ fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'server_error' }, 503));
+
+ const err = await getChatGPTAccessToken().catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(Error);
+ expect(err).not.toBeInstanceOf(ChatGPTAuthRequiredError);
+ await expect(getChatGPTStatus()).resolves.toMatchObject({ signedIn: true });
+ });
+
+ it('treats network failure as transient and keeps the stored tokens', async () => {
+ await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_1' });
+ fetchMock.mockRejectedValueOnce(new Error('fetch failed'));
+
+ const err = await getChatGPTAccessToken().catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(Error);
+ expect(err).not.toBeInstanceOf(ChatGPTAuthRequiredError);
+ await expect(getChatGPTStatus()).resolves.toMatchObject({ signedIn: true });
+ });
+
+ it('clears the store and requires sign-in when decryption fails', async () => {
+ await saveChatGPTTokens({ accessToken: makeAccessToken(3600), refreshToken: 'rt_1' });
+ const stored = readStoredFile();
+ stored.tokensEncrypted = 'corrupted';
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(stored));
+
+ await expect(getChatGPTAccessToken()).rejects.toBeInstanceOf(ChatGPTAuthRequiredError);
+ expect(fs.existsSync(AUTH_FILE)).toBe(false);
+ });
+});
+
+describe('signOutChatGPT', () => {
+ it('best-effort revokes the refresh token (with client_id) then clears the store', async () => {
+ await saveChatGPTTokens({
+ accessToken: makeAccessToken(3600),
+ refreshToken: 'rt_1',
+ idToken: makeIdToken(),
+ });
+ fetchMock.mockResolvedValueOnce(jsonResponse({}));
+
+ await signOutChatGPT();
+
+ // Revoke shape verified against codex-rs/login/src/auth/revoke.rs.
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe(CHATGPT_REVOKE_URL);
+ expect(JSON.parse(init.body as string)).toEqual({
+ token: 'rt_1',
+ token_type_hint: 'refresh_token',
+ client_id: CHATGPT_CLIENT_ID,
+ });
+
+ expect(fs.existsSync(AUTH_FILE)).toBe(false);
+ await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false });
+ });
+
+ it('still clears the store when revocation fails', async () => {
+ await saveChatGPTTokens({ accessToken: makeAccessToken(3600), refreshToken: 'rt_1' });
+ fetchMock.mockRejectedValueOnce(new Error('offline'));
+
+ await signOutChatGPT();
+
+ expect(fs.existsSync(AUTH_FILE)).toBe(false);
+ });
+
+ it('is a no-op-safe local clear when nothing is stored', async () => {
+ await signOutChatGPT();
+ expect(fetchMock).not.toHaveBeenCalled();
+ await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false });
+ });
+});
diff --git a/apps/x/packages/core/src/auth/chatgpt-auth.ts b/apps/x/packages/core/src/auth/chatgpt-auth.ts
new file mode 100644
index 00000000..0106d976
--- /dev/null
+++ b/apps/x/packages/core/src/auth/chatgpt-auth.ts
@@ -0,0 +1,389 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { WorkDir } from '../config/config.js';
+import {
+ CHATGPT_AUTH_CLAIM_NAMESPACE,
+ CHATGPT_CLIENT_ID,
+ CHATGPT_PROFILE_CLAIM_NAMESPACE,
+ CHATGPT_REDIRECT_URI,
+ CHATGPT_REFRESH_MARGIN_SECONDS,
+ CHATGPT_REVOKE_URL,
+ CHATGPT_TOKEN_URL,
+} from './chatgpt-constants.js';
+
+// "Sign in with ChatGPT" token layer. Owns storage + refresh of the OAuth
+// tokens acquired via the Codex CLI client (see chatgpt-constants.ts). The
+// interactive sign-in flow (PKCE + loopback server on 127.0.0.1:1455) lives
+// in the Electron main process and lands in Phase 2; it persists tokens here
+// via saveChatGPTTokens(). Consumers (the Codex Responses model client) must
+// go through getChatGPTAccessToken() and never read the store directly.
+//
+// IMPORTANT: never log token values — log events only.
+
+const AUTH_FILE = path.join(WorkDir, 'config', 'chatgpt-auth.json');
+
+// Token-at-rest encryption is provided by the Electron main process
+// (safeStorage) — core stays electron-free. When no cipher is wired (or the
+// OS keychain is unavailable) tokens are stored plaintext with a marker,
+// matching the existing GitHub token storage in apps/github-auth.ts.
+export interface TokenCipher {
+ isAvailable(): boolean;
+ encrypt(plain: string): string; // returns base64
+ decrypt(encrypted: string): string;
+}
+let cipher: TokenCipher | null = null;
+export function setTokenCipher(c: TokenCipher): void {
+ cipher = c;
+}
+
+/** The sensitive material — stored encrypted when the cipher is available. */
+type TokenMaterial = {
+ accessToken: string;
+ refreshToken: string;
+};
+
+type StoredChatGPTAuth = {
+ /** ChatGPT account id, parsed from the id_token (see extractIdentity). */
+ accountId?: string;
+ email?: string;
+ /** Unix seconds — the access token's `exp` claim. */
+ expiresAt: number;
+ createdAt: string;
+ /** base64 ciphertext of JSON TokenMaterial, via the injected cipher. */
+ tokensEncrypted?: string;
+ /** Plaintext fallback when no cipher/keychain is available. */
+ tokens?: TokenMaterial;
+ plaintext?: boolean;
+};
+
+/**
+ * Thrown when there is no usable ChatGPT session — never signed in, refresh
+ * token revoked/expired, or the stored tokens are unreadable. Callers should
+ * surface "Sign in with ChatGPT" and must not retry.
+ */
+export class ChatGPTAuthRequiredError extends Error {
+ constructor(message = 'ChatGPT sign-in required') {
+ super(message);
+ this.name = 'ChatGPTAuthRequiredError';
+ }
+}
+
+/**
+ * Decode a JWT's payload claims with a pure base64url decode — no signature
+ * verification. Fine for our use: we only mine identity/expiry hints from
+ * tokens we received directly from the token endpoint over TLS.
+ */
+export function decodeJwtClaims(jwt: string): Record | null {
+ const parts = jwt.split('.');
+ if (parts.length < 2 || !parts[1]) return null;
+ try {
+ const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
+ const parsed: unknown = JSON.parse(payload);
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
+ ? parsed as Record
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+function claimString(source: unknown, key: string): string | undefined {
+ if (source !== null && typeof source === 'object') {
+ const value = (source as Record)[key];
+ if (typeof value === 'string' && value.length > 0) return value;
+ }
+ return undefined;
+}
+
+/**
+ * Identity claims, per codex-rs/login/src/token_data.rs:
+ * - accountId: `chatgpt_account_id` inside "https://api.openai.com/auth"
+ * - email: root `email` claim, falling back to `email` inside
+ * "https://api.openai.com/profile"
+ * We check the id_token first (codex parses identity from it), then the
+ * access token, which carries the same auth claim namespace.
+ */
+function extractIdentity(tokens: Array): { accountId?: string; email?: string } {
+ let accountId: string | undefined;
+ let email: string | undefined;
+ for (const token of tokens) {
+ if (!token) continue;
+ const claims = decodeJwtClaims(token);
+ if (!claims) continue;
+ accountId ??= claimString(claims[CHATGPT_AUTH_CLAIM_NAMESPACE], 'chatgpt_account_id');
+ email ??= claimString(claims, 'email')
+ ?? claimString(claims[CHATGPT_PROFILE_CLAIM_NAMESPACE], 'email');
+ }
+ return { accountId, email };
+}
+
+async function readAuth(): Promise {
+ try {
+ return JSON.parse(await fs.readFile(AUTH_FILE, 'utf-8')) as StoredChatGPTAuth;
+ } catch {
+ return null;
+ }
+}
+
+async function writeAuth(auth: StoredChatGPTAuth): Promise {
+ await fs.mkdir(path.dirname(AUTH_FILE), { recursive: true });
+ await fs.writeFile(AUTH_FILE, JSON.stringify(auth, null, 2), { mode: 0o600 });
+}
+
+async function clearStore(): Promise {
+ await fs.rm(AUTH_FILE, { force: true });
+}
+
+/**
+ * Read the sensitive token material from a stored entry. Returns null when it
+ * cannot be read; a failed DECRYPT additionally clears the store (keychain
+ * changed / corrupt ciphertext is unrecoverable — force a clean re-sign-in,
+ * mirroring the GitHub token path).
+ */
+async function getTokenMaterial(auth: StoredChatGPTAuth): Promise {
+ if (auth.tokensEncrypted && cipher?.isAvailable()) {
+ try {
+ const material = JSON.parse(cipher.decrypt(auth.tokensEncrypted)) as TokenMaterial;
+ if (typeof material.accessToken === 'string' && typeof material.refreshToken === 'string') {
+ return material;
+ }
+ throw new Error('malformed token material');
+ } catch {
+ console.warn('[ChatGPTAuth] Failed to decrypt stored tokens; clearing auth');
+ await clearStore();
+ return null;
+ }
+ }
+ if (auth.tokens) return auth.tokens;
+ if (auth.tokensEncrypted) {
+ // Encrypted store but no cipher wired (e.g. keychain unavailable this
+ // launch). Don't clear — the tokens may become readable again.
+ console.warn('[ChatGPTAuth] Stored tokens are encrypted but no cipher is available');
+ }
+ return null;
+}
+
+/**
+ * Persist tokens (called by the Phase 2 sign-in flow and by refresh).
+ * Derives expiry from the access token's `exp` claim and identity from the
+ * id_token / access token claims; existing identity is preserved when a
+ * refresh response doesn't carry the claims.
+ */
+export async function saveChatGPTTokens(input: {
+ accessToken: string;
+ refreshToken: string;
+ idToken?: string;
+}): Promise<{ accountId?: string; email?: string }> {
+ const existing = await readAuth();
+
+ const identity = extractIdentity([input.idToken, input.accessToken]);
+ const accountId = identity.accountId ?? existing?.accountId;
+ const email = identity.email ?? existing?.email;
+
+ const exp = decodeJwtClaims(input.accessToken)?.exp;
+ let expiresAt: number;
+ if (typeof exp === 'number') {
+ expiresAt = exp;
+ } else {
+ // The token endpoint returns no expires_in (verified against
+ // codex-rs/login/src/auth/manager.rs) — the JWT exp claim is the only
+ // expiry source. Without it, assume a conservative 1h lifetime.
+ console.warn('[ChatGPTAuth] Access token has no exp claim; assuming 1h lifetime');
+ expiresAt = Math.floor(Date.now() / 1000) + 3600;
+ }
+
+ const auth: StoredChatGPTAuth = {
+ ...(accountId ? { accountId } : {}),
+ ...(email ? { email } : {}),
+ expiresAt,
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
+ };
+ const material: TokenMaterial = {
+ accessToken: input.accessToken,
+ refreshToken: input.refreshToken,
+ };
+ if (cipher?.isAvailable()) {
+ auth.tokensEncrypted = cipher.encrypt(JSON.stringify(material));
+ } else {
+ auth.tokens = material;
+ auth.plaintext = true;
+ }
+ await writeAuth(auth);
+ return { accountId, email };
+}
+
+/**
+ * Exchange an authorization code for tokens and persist them (called by the
+ * main process sign-in flow after the loopback callback). Request/response
+ * shape verified against codex-rs/login/src/server.rs
+ * (`exchange_code_for_tokens`): form-encoded POST — unlike the JSON refresh —
+ * response `{ id_token, access_token, refresh_token }`, no expires_in.
+ * Returns the parsed identity for the caller to surface.
+ */
+export async function exchangeChatGPTCode(
+ code: string,
+ codeVerifier: string,
+): Promise<{ accountId?: string; email?: string }> {
+ const res = await fetch(CHATGPT_TOKEN_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: CHATGPT_REDIRECT_URI,
+ client_id: CHATGPT_CLIENT_ID,
+ code_verifier: codeVerifier,
+ }).toString(),
+ signal: AbortSignal.timeout(30_000),
+ });
+ if (!res.ok) {
+ throw new Error(`ChatGPT token exchange failed: HTTP ${res.status}`);
+ }
+ const body = await res.json() as {
+ id_token?: string;
+ access_token?: string;
+ refresh_token?: string;
+ };
+ if (!body.access_token || !body.refresh_token) {
+ throw new Error('ChatGPT token exchange response is missing tokens');
+ }
+ const identity = await saveChatGPTTokens({
+ accessToken: body.access_token,
+ refreshToken: body.refresh_token,
+ ...(body.id_token ? { idToken: body.id_token } : {}),
+ });
+ console.log('[ChatGPTAuth] Sign-in token exchange complete');
+ return identity;
+}
+
+// Single-flight refresh: concurrent expired-token callers share one request
+// (same pattern as the Rowboat gateway token in auth/tokens.ts). One refresh
+// owner matters here — parallel refreshes racing on one refresh_token can
+// invalidate each other's grant.
+let refreshInFlight: Promise | null = null;
+
+async function performRefresh(refreshToken: string): Promise {
+ // Request/response shape verified against codex-rs/login/src/auth/manager.rs:
+ // JSON body (not form-encoded), response may omit refresh_token (keep the
+ // old one) and never carries expires_in.
+ let res: Response;
+ try {
+ res = await fetch(CHATGPT_TOKEN_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ client_id: CHATGPT_CLIENT_ID,
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken,
+ }),
+ signal: AbortSignal.timeout(30_000),
+ });
+ } catch (error) {
+ // Network failure: transient — keep the stored tokens so the next
+ // call retries instead of forcing a re-sign-in.
+ const message = error instanceof Error ? error.message : String(error);
+ throw new Error(`ChatGPT token refresh failed: ${message}`);
+ }
+
+ if (res.status === 400 || res.status === 401) {
+ // Refresh token revoked or expired — unrecoverable without the user.
+ console.log(`[ChatGPTAuth] Refresh rejected (HTTP ${res.status}); signing out`);
+ await clearStore();
+ throw new ChatGPTAuthRequiredError('ChatGPT session expired. Please sign in again.');
+ }
+ if (!res.ok) {
+ // 5xx / rate limit: transient — keep the stored tokens.
+ throw new Error(`ChatGPT token refresh failed: HTTP ${res.status}`);
+ }
+
+ const body = await res.json() as {
+ id_token?: string;
+ access_token?: string;
+ refresh_token?: string;
+ };
+ if (!body.access_token) {
+ throw new Error('ChatGPT token refresh returned no access token');
+ }
+
+ await saveChatGPTTokens({
+ accessToken: body.access_token,
+ refreshToken: body.refresh_token || refreshToken,
+ ...(body.id_token ? { idToken: body.id_token } : {}),
+ });
+ console.log('[ChatGPTAuth] Access token refreshed');
+ return body.access_token;
+}
+
+/**
+ * The one seam for consumers (the Codex Responses model client): returns a
+ * valid access token, transparently refreshing when within 5 minutes of
+ * expiry. Throws ChatGPTAuthRequiredError when there is no usable session.
+ */
+export async function getChatGPTAccessToken(): Promise {
+ const auth = await readAuth();
+ if (!auth) {
+ throw new ChatGPTAuthRequiredError();
+ }
+ const material = await getTokenMaterial(auth);
+ if (!material) {
+ throw new ChatGPTAuthRequiredError();
+ }
+
+ const now = Math.floor(Date.now() / 1000);
+ if (auth.expiresAt - now > CHATGPT_REFRESH_MARGIN_SECONDS) {
+ return material.accessToken;
+ }
+
+ if (!refreshInFlight) {
+ refreshInFlight = performRefresh(material.refreshToken).finally(() => {
+ refreshInFlight = null;
+ });
+ }
+ return refreshInFlight;
+}
+
+/** Connection state for the UI. Never returns token values. */
+export async function getChatGPTStatus(): Promise<{ signedIn: boolean; email?: string; accountId?: string }> {
+ const auth = await readAuth();
+ if (!auth || (!auth.tokensEncrypted && !auth.tokens)) {
+ return { signedIn: false };
+ }
+ return {
+ signedIn: true,
+ ...(auth.email ? { email: auth.email } : {}),
+ ...(auth.accountId ? { accountId: auth.accountId } : {}),
+ };
+}
+
+/**
+ * Sign out: best-effort revocation at auth.openai.com (endpoint + request
+ * shape verified in codex-rs/login/src/auth/revoke.rs — refresh token first,
+ * with client_id; access token as fallback, without), then clear the local
+ * store. Revocation failure never blocks the local sign-out.
+ */
+export async function signOutChatGPT(): Promise {
+ const auth = await readAuth();
+ if (auth) {
+ const material = await getTokenMaterial(auth);
+ if (material) {
+ const body = material.refreshToken
+ ? { token: material.refreshToken, token_type_hint: 'refresh_token', client_id: CHATGPT_CLIENT_ID }
+ : { token: material.accessToken, token_type_hint: 'access_token' };
+ try {
+ const res = await fetch(CHATGPT_REVOKE_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!res.ok) {
+ console.warn(`[ChatGPTAuth] Token revocation returned HTTP ${res.status}; continuing with local sign-out`);
+ }
+ } catch {
+ console.warn('[ChatGPTAuth] Token revocation failed; continuing with local sign-out');
+ }
+ }
+ }
+ await clearStore();
+ console.log('[ChatGPTAuth] Signed out');
+}
diff --git a/apps/x/packages/core/src/auth/chatgpt-constants.ts b/apps/x/packages/core/src/auth/chatgpt-constants.ts
new file mode 100644
index 00000000..6e64c058
--- /dev/null
+++ b/apps/x/packages/core/src/auth/chatgpt-constants.ts
@@ -0,0 +1,97 @@
+// OAuth constants for "Sign in with ChatGPT" (ChatGPT subscription auth).
+//
+// Rowboat authenticates against OpenAI's auth server using the SAME public
+// client the open-source Codex CLI uses. Every value below was verified
+// against the openai/codex sources on 2026-07-15 — do not edit from memory;
+// re-check the linked files instead.
+//
+// Sources (github.com/openai/codex, main branch):
+// - codex-rs/login/src/auth/manager.rs → CLIENT_ID, refresh request shape,
+// 5-minute access-token refresh window
+// - codex-rs/login/src/server.rs → DEFAULT_ISSUER, /oauth/authorize,
+// /oauth/token, DEFAULT_PORT 1455, /auth/callback, scopes, extra
+// authorize params
+// - codex-rs/login/src/auth/revoke.rs → /oauth/revoke request shape
+// - codex-rs/login/src/token_data.rs → JWT claim names for account id
+// and email
+
+/**
+ * OAuth client id of the official Codex CLI.
+ * Source: codex-rs/login/src/auth/manager.rs (`pub const CLIENT_ID`).
+ * Client ids are public identifiers, not secrets.
+ */
+export const CHATGPT_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
+
+/** Source: codex-rs/login/src/server.rs (`DEFAULT_ISSUER`). */
+export const CHATGPT_ISSUER = 'https://auth.openai.com';
+
+/** Source: codex-rs/login/src/server.rs (`build_authorize_url`: `{issuer}/oauth/authorize`). */
+export const CHATGPT_AUTHORIZE_URL = `${CHATGPT_ISSUER}/oauth/authorize`;
+
+/**
+ * Token endpoint, used for both code exchange and refresh.
+ * Source: codex-rs/login/src/server.rs (`exchange_code_for_tokens`) and
+ * codex-rs/login/src/auth/manager.rs (refresh POSTs here).
+ *
+ * NOTE (verified in manager.rs): the refresh request is a JSON body —
+ * `{ client_id, grant_type: "refresh_token", refresh_token }` with
+ * Content-Type application/json, NOT the form-encoded body standard OAuth
+ * clients send. The response is `{ id_token?, access_token?, refresh_token? }`
+ * and carries NO `expires_in` — expiry must be read from the new access
+ * token's `exp` claim.
+ */
+export const CHATGPT_TOKEN_URL = `${CHATGPT_ISSUER}/oauth/token`;
+
+/**
+ * Revocation endpoint. Source: codex-rs/login/src/auth/revoke.rs — JSON POST
+ * `{ token, token_type_hint: "refresh_token"|"access_token", client_id }`
+ * (client_id only when revoking a refresh token), 10s timeout.
+ */
+export const CHATGPT_REVOKE_URL = `${CHATGPT_ISSUER}/oauth/revoke`;
+
+/**
+ * The loopback callback the Codex client id is registered for. The port is
+ * fixed — unlike Rowboat's DCR providers there is no scan-to-next-port
+ * fallback, because the redirect URI is pre-registered at OpenAI.
+ * Source: codex-rs/login/src/server.rs (`DEFAULT_PORT: u16 = 1455`,
+ * `http://localhost:{port}/auth/callback`).
+ */
+export const CHATGPT_CALLBACK_PORT = 1455;
+export const CHATGPT_CALLBACK_PATH = '/auth/callback';
+export const CHATGPT_REDIRECT_URI = `http://localhost:${CHATGPT_CALLBACK_PORT}${CHATGPT_CALLBACK_PATH}`;
+
+/**
+ * Scopes to request at authorize time (Phase 2).
+ * Source: codex-rs/login/src/server.rs requests
+ * "openid profile email offline_access api.connectors.read api.connectors.invoke";
+ * we deliberately drop the two `api.connectors.*` scopes — Rowboat only needs
+ * identity + refresh (`offline_access`) for model calls, matching what Zed's
+ * ChatGPT provider requests.
+ */
+export const CHATGPT_SCOPES = ['openid', 'profile', 'email', 'offline_access'];
+
+/**
+ * Extra authorize-URL query params the Codex flow sends (Phase 2).
+ * Source: codex-rs/login/src/server.rs (`build_authorize_url`).
+ */
+export const CHATGPT_EXTRA_AUTHORIZE_PARAMS: Record = {
+ id_token_add_organizations: 'true',
+ codex_cli_simplified_flow: 'true',
+};
+
+/**
+ * Refresh the access token when it is within this margin of expiry.
+ * Source: codex-rs/login/src/auth/manager.rs
+ * (`CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES: i64 = 5`).
+ */
+export const CHATGPT_REFRESH_MARGIN_SECONDS = 5 * 60;
+
+/**
+ * JWT claim namespaces. Source: codex-rs/login/src/token_data.rs — the
+ * ChatGPT account id is `chatgpt_account_id` inside the
+ * "https://api.openai.com/auth" claim of the id_token; email is the root
+ * `email` claim with a fallback to `email` inside
+ * "https://api.openai.com/profile".
+ */
+export const CHATGPT_AUTH_CLAIM_NAMESPACE = 'https://api.openai.com/auth';
+export const CHATGPT_PROFILE_CLAIM_NAMESPACE = 'https://api.openai.com/profile';
diff --git a/apps/x/packages/core/src/models/codex.test.ts b/apps/x/packages/core/src/models/codex.test.ts
new file mode 100644
index 00000000..8fe0e70a
--- /dev/null
+++ b/apps/x/packages/core/src/models/codex.test.ts
@@ -0,0 +1,165 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ normalizeRequestBody,
+ aggregateSseResponse,
+ listCodexModels,
+ codexStoreMiddleware,
+ CODEX_BASE_URL,
+} from './codex.js';
+import type { LanguageModelV4, LanguageModelV4CallOptions } from '@ai-sdk/provider';
+
+vi.mock('../auth/chatgpt-auth.js', () => ({
+ getChatGPTAccessToken: vi.fn(async () => 'test-access-token'),
+ getChatGPTStatus: vi.fn(async () => ({ signedIn: true, accountId: 'acct-123' })),
+}));
+
+describe('normalizeRequestBody', () => {
+ it('enforces the Codex wire contract on a bare request', () => {
+ const { body, forcedStream } = normalizeRequestBody(JSON.stringify({
+ model: 'gpt-5.5',
+ input: [],
+ store: true,
+ max_output_tokens: 4096,
+ }));
+ const parsed = JSON.parse(body);
+ expect(parsed.store).toBe(false);
+ expect(parsed).not.toHaveProperty('max_output_tokens');
+ expect(parsed.instructions).toBe('You are a helpful assistant.');
+ expect(parsed.include).toContain('reasoning.encrypted_content');
+ expect(parsed.reasoning).toEqual({ summary: 'auto' });
+ expect(parsed.stream).toBe(true);
+ expect(forcedStream).toBe(true);
+ });
+
+ it('preserves caller instructions, reasoning fields, and existing includes', () => {
+ const { body, forcedStream } = normalizeRequestBody(JSON.stringify({
+ instructions: 'Be terse.',
+ include: ['message.output_text.logprobs'],
+ reasoning: { effort: 'high', summary: 'detailed' },
+ stream: true,
+ }));
+ const parsed = JSON.parse(body);
+ expect(parsed.instructions).toBe('Be terse.');
+ expect(parsed.include).toEqual(
+ expect.arrayContaining(['message.output_text.logprobs', 'reasoning.encrypted_content']),
+ );
+ expect(parsed.reasoning).toEqual({ effort: 'high', summary: 'detailed' });
+ expect(forcedStream).toBe(false);
+ });
+
+ it('passes non-JSON bodies through untouched', () => {
+ expect(normalizeRequestBody('not json')).toEqual({ body: 'not json', forcedStream: false });
+ });
+});
+
+function sseResponse(events: string[]): Response {
+ const body = events.map((e) => `data: ${e}`).join('\n\n') + '\n\n';
+ return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } });
+}
+
+describe('aggregateSseResponse', () => {
+ it('returns the response.completed payload as plain JSON', async () => {
+ const res = await aggregateSseResponse(sseResponse([
+ JSON.stringify({ type: 'response.created' }),
+ JSON.stringify({ type: 'response.completed', response: { id: 'resp_1', output: [] } }),
+ ]));
+ expect(res.status).toBe(200);
+ expect(res.headers.get('content-type')).toBe('application/json');
+ expect(await res.json()).toEqual({ id: 'resp_1', output: [] });
+ });
+
+ it('surfaces response.failed as an error response', async () => {
+ const res = await aggregateSseResponse(sseResponse([
+ JSON.stringify({ type: 'response.failed', response: { error: { message: 'quota exceeded' } } }),
+ ]));
+ expect(res.status).toBe(502);
+ const body = await res.json() as { error: { message: string } };
+ expect(body.error.message).toBe('quota exceeded');
+ });
+
+ it('errors when the stream ends without a terminal event', async () => {
+ const res = await aggregateSseResponse(sseResponse([
+ JSON.stringify({ type: 'response.created' }),
+ ]));
+ expect(res.status).toBe(502);
+ });
+});
+
+describe('codexStoreMiddleware', () => {
+ it('injects providerOptions.openai.store=false so the SDK never emits item references', async () => {
+ const params = {
+ prompt: [],
+ providerOptions: { openai: { reasoningEffort: 'high' } },
+ } as unknown as LanguageModelV4CallOptions;
+ const out = await codexStoreMiddleware.transformParams!({
+ type: 'stream',
+ params,
+ model: {} as LanguageModelV4,
+ });
+ expect(out.providerOptions).toEqual({
+ openai: { reasoningEffort: 'high', store: false },
+ });
+ });
+
+ it('overrides a caller-supplied store=true', async () => {
+ const params = {
+ prompt: [],
+ providerOptions: { openai: { store: true } },
+ } as unknown as LanguageModelV4CallOptions;
+ const out = await codexStoreMiddleware.transformParams!({
+ type: 'generate',
+ params,
+ model: {} as LanguageModelV4,
+ });
+ expect(out.providerOptions?.openai?.store).toBe(false);
+ });
+});
+
+describe('listCodexModels', () => {
+ const realFetch = globalThis.fetch;
+ afterEach(() => {
+ globalThis.fetch = realFetch;
+ vi.restoreAllMocks();
+ });
+ beforeEach(() => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ it('discovers models with auth + originator headers, filtering hidden and sorting by priority', async () => {
+ let seenHeaders: Headers | undefined;
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ expect(String(input)).toContain(`${CODEX_BASE_URL}/models?client_version=`);
+ seenHeaders = new Headers(init?.headers);
+ return new Response(JSON.stringify({
+ models: [
+ { slug: 'gpt-5.4', priority: 2, visibility: 'list' },
+ { slug: 'codex-auto-review', priority: 0, visibility: 'hide' },
+ { slug: 'gpt-5.5', priority: 1, visibility: 'list', display_name: 'GPT-5.5' },
+ ],
+ }), { status: 200 });
+ }) as typeof fetch;
+
+ const result = await listCodexModels();
+ expect(result.providers).toHaveLength(1);
+ expect(result.providers[0]?.id).toBe('codex');
+ expect(result.providers[0]?.name).toBe('OpenAI Codex');
+ expect(result.providers[0]?.models).toEqual([
+ { id: 'gpt-5.5', name: 'GPT-5.5', reasoning: true },
+ { id: 'gpt-5.4', reasoning: true },
+ ]);
+ expect(seenHeaders?.get('Authorization')).toBe('Bearer test-access-token');
+ expect(seenHeaders?.get('chatgpt-account-id')).toBe('acct-123');
+ expect(seenHeaders?.get('originator')).toBe('codex_cli_rs');
+ });
+
+ it('falls back to the hardcoded list when discovery fails', async () => {
+ globalThis.fetch = vi.fn(async () => {
+ throw new Error('offline');
+ }) as typeof fetch;
+
+ const result = await listCodexModels();
+ const ids = result.providers[0]?.models.map((m) => m.id);
+ expect(ids).toContain('gpt-5.6-sol');
+ expect(result.providers[0]?.models.every((m) => m.reasoning === true)).toBe(true);
+ });
+});
diff --git a/apps/x/packages/core/src/models/codex.ts b/apps/x/packages/core/src/models/codex.ts
new file mode 100644
index 00000000..f14fec1f
--- /dev/null
+++ b/apps/x/packages/core/src/models/codex.ts
@@ -0,0 +1,303 @@
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { ProviderV4, LanguageModelV4Middleware } from '@ai-sdk/provider';
+import { createOpenAI } from '@ai-sdk/openai';
+import { wrapLanguageModel } from 'ai';
+import { getChatGPTAccessToken, getChatGPTStatus } from '../auth/chatgpt-auth.js';
+
+// "ChatGPT subscription" model provider (flavor "codex"): runs model calls
+// against the Codex backend that powers the Codex CLI, authorized by the
+// "Sign in with ChatGPT" OAuth session (auth/chatgpt-auth.ts) instead of an
+// API key. Like the "rowboat" gateway flavor, it has no models.json entry —
+// resolveProviderConfig returns a bare { flavor: "codex" } and auth is
+// injected per request here.
+//
+// The backend speaks ONLY the OpenAI Responses API, with quirks that every
+// third-party client (Codex CLI itself, Zed, others) handles the same way —
+// see codexFetch. Wire shape cross-checked against the open-source Codex CLI
+// and known-working third-party integrations on 2026-07-17.
+
+export const CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex';
+
+// Cloudflare in front of chatgpt.com challenges requests whose `originator`
+// isn't a known first-party Codex client, regardless of valid auth — so we
+// identify as codex_cli_rs, the same approach other subscription clients
+// ship.
+const CODEX_ORIGINATOR = 'codex_cli_rs';
+
+// The backend gates the model catalog by client_version — a version that
+// predates a model family never sees it (e.g. 0.142.x gets no gpt-5.6-*).
+// When the codex CLI is installed locally, mirror the version its own cache
+// records so our catalog matches what `codex` shows in the terminal;
+// otherwise fall back to a pinned recent release.
+const CODEX_DEFAULT_CLIENT_VERSION = '0.144.5';
+let clientVersionPromise: Promise | null = null;
+function codexClientVersion(): Promise {
+ clientVersionPromise ??= (async () => {
+ try {
+ const cachePath = path.join(os.homedir(), '.codex', 'models_cache.json');
+ const parsed = JSON.parse(await fs.readFile(cachePath, 'utf-8')) as { client_version?: string };
+ if (typeof parsed.client_version === 'string' && /^\d+\.\d+\.\d+$/.test(parsed.client_version)) {
+ return parsed.client_version;
+ }
+ } catch { /* no local codex CLI install */ }
+ return CODEX_DEFAULT_CLIENT_VERSION;
+ })();
+ return clientVersionPromise;
+}
+
+// Used when live discovery (listCodexModels) is unreachable. Discovery wins
+// outright when it returns anything: the backend rejects retired slugs with
+// HTTP 400, so appending stale hardcoded ids to a live list only creates
+// broken picker rows.
+const CODEX_FALLBACK_MODELS = [
+ 'gpt-5.6-sol',
+ 'gpt-5.6-terra',
+ 'gpt-5.6-luna',
+ 'gpt-5.5',
+ 'gpt-5.4',
+ 'gpt-5.4-mini',
+];
+
+/**
+ * Wire-level normalization for the Codex backend. Applied via fetch (not
+ * providerOptions) so EVERY caller — chat turns, generateText one-shots,
+ * generateObject classifiers, connection tests — gets a valid request
+ * without knowing codex specifics:
+ *
+ * - `store: false` is mandatory (the backend rejects store:true).
+ * - `stream: true` is mandatory (stream:false is rejected outright);
+ * non-streaming callers get their SSE aggregated back into a plain JSON
+ * response below.
+ * - `max_output_tokens` is rejected ("Unsupported parameter") — dropped.
+ * - `instructions` must be non-empty.
+ * - `include: ["reasoning.encrypted_content"]` keeps reasoning usable
+ * across tool-call steps despite store:false (the AI SDK relays the
+ * encrypted blobs back on subsequent requests).
+ * - a reasoning summary is requested by default so the UI has visible
+ * reasoning even when the user picked no explicit effort.
+ */
+export function normalizeRequestBody(raw: string): { body: string; forcedStream: boolean } {
+ let parsed: Record;
+ try {
+ const value: unknown = JSON.parse(raw);
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
+ return { body: raw, forcedStream: false };
+ }
+ parsed = value as Record;
+ } catch {
+ return { body: raw, forcedStream: false };
+ }
+
+ parsed.store = false;
+ delete parsed.max_output_tokens;
+ if (typeof parsed.instructions !== 'string' || parsed.instructions.length === 0) {
+ parsed.instructions = 'You are a helpful assistant.';
+ }
+ const include = new Set(Array.isArray(parsed.include) ? parsed.include as unknown[] : []);
+ include.add('reasoning.encrypted_content');
+ parsed.include = [...include];
+ const reasoning = (parsed.reasoning !== null && typeof parsed.reasoning === 'object')
+ ? parsed.reasoning as Record
+ : {};
+ reasoning.summary ??= 'auto';
+ parsed.reasoning = reasoning;
+
+ let forcedStream = false;
+ if (parsed.stream !== true) {
+ parsed.stream = true;
+ forcedStream = true;
+ }
+ return { body: JSON.stringify(parsed), forcedStream };
+}
+
+/**
+ * Collapse a forced SSE stream back into the plain JSON response the
+ * non-streaming caller expects: the terminal `response.completed` event
+ * carries the full response object.
+ */
+export async function aggregateSseResponse(res: Response): Promise {
+ const text = await res.text();
+ let completed: unknown;
+ let failure: string | undefined;
+ for (const line of text.split('\n')) {
+ if (!line.startsWith('data:')) continue;
+ const payload = line.slice(5).trim();
+ if (!payload || payload === '[DONE]') continue;
+ let event: { type?: string; response?: unknown; message?: string };
+ try {
+ event = JSON.parse(payload) as typeof event;
+ } catch {
+ continue;
+ }
+ if (event.type === 'response.completed') {
+ completed = event.response;
+ } else if (event.type === 'response.failed') {
+ const error = (event.response as { error?: { message?: string } } | undefined)?.error;
+ failure = error?.message ?? 'Codex request failed';
+ } else if (event.type === 'error') {
+ failure = event.message ?? 'Codex request failed';
+ }
+ }
+ const headers = new Headers(res.headers);
+ headers.set('content-type', 'application/json');
+ headers.delete('content-length');
+ headers.delete('content-encoding');
+ if (completed !== undefined) {
+ return new Response(JSON.stringify(completed), { status: 200, headers });
+ }
+ return new Response(
+ JSON.stringify({ error: { message: failure ?? 'Codex stream ended without a completed response' } }),
+ { status: 502, headers },
+ );
+}
+
+/**
+ * Rewrite subscription-limit errors into something a user can act on. The
+ * backend's 429 carries `plan_type` / `resets_at` (unix seconds) on the
+ * error object; the raw message is unhelpful. Everything else passes
+ * through untouched (body re-wrapped since reading consumed it).
+ */
+async function normalizeErrorResponse(res: Response): Promise {
+ const raw = await res.text();
+ const headers = new Headers(res.headers);
+ headers.delete('content-length');
+ headers.delete('content-encoding');
+ let body = raw;
+ if (res.status === 429) {
+ try {
+ const parsed = JSON.parse(raw) as {
+ error?: { message?: string; plan_type?: string; resets_at?: number };
+ };
+ if (parsed.error) {
+ const plan = parsed.error.plan_type ? ` (${parsed.error.plan_type} plan)` : '';
+ let when = '';
+ if (typeof parsed.error.resets_at === 'number') {
+ const minutes = Math.max(1, Math.ceil((parsed.error.resets_at * 1000 - Date.now()) / 60_000));
+ when = minutes >= 120 ? ` Try again in ~${Math.round(minutes / 60)}h.` : ` Try again in ~${minutes} min.`;
+ }
+ parsed.error.message = `You have hit your ChatGPT subscription usage limit${plan}.${when}`;
+ body = JSON.stringify(parsed);
+ }
+ } catch { /* keep the original body */ }
+ }
+ return new Response(body, { status: res.status, statusText: res.statusText, headers });
+}
+
+// Auth + identity + Cloudflare headers on every request; body normalization
+// on Responses calls. Throws ChatGPTAuthRequiredError when signed out —
+// callers surface "Sign in with ChatGPT".
+const codexFetch: typeof fetch = async (input, init) => {
+ const token = await getChatGPTAccessToken();
+ const { accountId } = await getChatGPTStatus();
+ const headers = new Headers(init?.headers);
+ headers.set('Authorization', `Bearer ${token}`);
+ if (accountId) headers.set('chatgpt-account-id', accountId);
+ headers.set('originator', CODEX_ORIGINATOR);
+ headers.set('User-Agent', `${CODEX_ORIGINATOR}/${await codexClientVersion()} (Rowboat)`);
+
+ let body = init?.body;
+ let forcedStream = false;
+ if (typeof body === 'string') {
+ ({ body, forcedStream } = normalizeRequestBody(body));
+ }
+
+ const res = await fetch(input, { ...init, headers, ...(body === undefined ? {} : { body }) });
+ if (!res.ok) return normalizeErrorResponse(res);
+ if (forcedStream) return aggregateSseResponse(res);
+ return res;
+};
+
+/**
+ * The SDK must BELIEVE store is false — forcing it only on the wire is not
+ * enough. When the SDK thinks store is on (its default), it echoes prior
+ * assistant text/reasoning parts as `item_reference` entries, and the
+ * stateless Codex backend 404s them ("Items are not persisted when `store`
+ * is set to false"). With providerOptions.openai.store=false its message
+ * converter emits full items instead — text with content, reasoning with
+ * the round-tripped encrypted_content — so every request is
+ * self-contained. Injected via middleware so no caller has to know.
+ */
+export const codexStoreMiddleware: LanguageModelV4Middleware = {
+ specificationVersion: 'v4',
+ transformParams: async ({ params }) => ({
+ ...params,
+ providerOptions: {
+ ...params.providerOptions,
+ openai: {
+ ...params.providerOptions?.openai,
+ store: false,
+ },
+ },
+ }),
+};
+
+/**
+ * AI SDK provider for the codex flavor. `languageModel()` on the OpenAI
+ * provider resolves to the Responses API model (the only API the Codex
+ * backend speaks); auth is injected per request by codexFetch, so the
+ * apiKey here is a placeholder that never reaches the wire.
+ */
+export function getCodexProvider(): ProviderV4 {
+ const provider = createOpenAI({
+ baseURL: CODEX_BASE_URL,
+ apiKey: 'chatgpt-subscription',
+ fetch: codexFetch,
+ });
+ return {
+ ...provider,
+ languageModel: (modelId: string) => wrapLanguageModel({
+ model: provider.languageModel(modelId),
+ middleware: codexStoreMiddleware,
+ }),
+ };
+}
+
+type ProviderSummary = {
+ id: string;
+ name: string;
+ models: Array<{
+ id: string;
+ name?: string;
+ reasoning?: boolean;
+ }>;
+};
+
+/**
+ * Models available to the signed-in ChatGPT subscription, shaped like
+ * listGatewayModels for the models:list merge. Live discovery against the
+ * backend's own catalog; the hardcoded fallback covers offline/errors.
+ * Every codex model is a reasoning model, so the flag is set directly
+ * (models.dev doesn't know this flavor).
+ */
+export async function listCodexModels(): Promise<{ providers: ProviderSummary[] }> {
+ let discovered: Array<{ id: string; name?: string }> = [];
+ try {
+ const res = await codexFetch(`${CODEX_BASE_URL}/models?client_version=${await codexClientVersion()}`);
+ if (res.ok) {
+ const body = await res.json() as {
+ models?: Array<{ slug?: string; display_name?: string; visibility?: string; priority?: number }>;
+ };
+ discovered = (body.models ?? [])
+ // Utility models (e.g. codex-auto-review) carry visibility
+ // "hide"; picker-visible ones carry "list".
+ .filter((m): m is { slug: string; display_name?: string; priority?: number } =>
+ typeof m.slug === 'string' && m.slug.length > 0
+ && m.visibility !== 'hide' && m.visibility !== 'hidden')
+ .sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER))
+ .map((m) => ({ id: m.slug, ...(m.display_name ? { name: m.display_name } : {}) }));
+ }
+ } catch (error) {
+ console.warn('[Codex] Model discovery failed; using fallback list:', error instanceof Error ? error.message : error);
+ }
+ const models = (discovered.length > 0 ? discovered : CODEX_FALLBACK_MODELS.map((id) => ({ id })))
+ .map((m) => ({ ...m, reasoning: true }));
+ return {
+ providers: [{
+ id: 'codex',
+ name: 'OpenAI Codex',
+ models,
+ }],
+ };
+}
diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts
index 183faeac..89527ebf 100644
--- a/apps/x/packages/core/src/models/defaults.ts
+++ b/apps/x/packages/core/src/models/defaults.ts
@@ -78,6 +78,11 @@ export async function resolveProviderConfig(name: string): Promise("modelConfigRepo");
const cfg = await repo.getConfig();
const entry = cfg.providers?.[name];
diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts
index e124c924..a87bd52d 100644
--- a/apps/x/packages/core/src/models/models.ts
+++ b/apps/x/packages/core/src/models/models.ts
@@ -9,6 +9,7 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
import z from "zod";
import { getGatewayProvider } from "./gateway.js";
+import { getCodexProvider } from "./codex.js";
import { getDefaultModelAndProvider, resolveProviderConfig } from "./defaults.js";
import { getChatModelIds } from "./models-dev.js";
import { withUseCase } from "../analytics/use_case.js";
@@ -81,6 +82,8 @@ export function createProvider(config: z.infer): ProviderV4 {
}) as unknown as ProviderV4;
case "rowboat":
return getGatewayProvider();
+ case "codex":
+ return getCodexProvider();
default:
throw new Error(`Unsupported provider flavor: ${config.flavor}`);
}
diff --git a/apps/x/packages/core/src/models/reasoning.test.ts b/apps/x/packages/core/src/models/reasoning.test.ts
index ad6af975..eff171c2 100644
--- a/apps/x/packages/core/src/models/reasoning.test.ts
+++ b/apps/x/packages/core/src/models/reasoning.test.ts
@@ -68,6 +68,13 @@ describe("mapReasoningEffort", () => {
expect(mapReasoningEffort("rowboat", "x/y", "high", false)).toBeUndefined();
});
+ it("maps codex like openai but forgiving on unknown support (models.dev has no codex flavor)", () => {
+ expect(mapReasoningEffort("codex", "gpt-5.5", "high", undefined)).toEqual({
+ providerOptions: { openai: { reasoningEffort: "high" } },
+ });
+ expect(mapReasoningEffort("codex", "gpt-5.5", "medium", false)).toBeUndefined();
+ });
+
it("sends nothing for flavors without a safe parameter", () => {
expect(mapReasoningEffort("ollama", "gpt-oss", "high", true)).toBeUndefined();
expect(mapReasoningEffort("openai-compatible", "my-vllm-model", "high", true)).toBeUndefined();
diff --git a/apps/x/packages/core/src/models/reasoning.ts b/apps/x/packages/core/src/models/reasoning.ts
index c85e09c3..9d3e2480 100644
--- a/apps/x/packages/core/src/models/reasoning.ts
+++ b/apps/x/packages/core/src/models/reasoning.ts
@@ -95,6 +95,13 @@ export function mapReasoningEffort(
if (supportsReasoning === false) return undefined;
return { providerOptions: { openrouter: { reasoning: { effort } } } };
}
+ case "codex": {
+ // Every ChatGPT-subscription model reasons, but models.dev has
+ // no "codex" flavor so support reads as unknown — map unless
+ // known-false rather than failing closed.
+ if (supportsReasoning === false) return undefined;
+ return { providerOptions: { openai: { reasoningEffort: effort } } };
+ }
default:
return undefined;
}
diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts
index 98191887..2d657b77 100644
--- a/apps/x/packages/shared/src/ipc.ts
+++ b/apps/x/packages/shared/src/ipc.ts
@@ -783,6 +783,53 @@ const ipcSchemas = {
}),
res: z.null(),
},
+ // --- "Sign in with ChatGPT" (subscription OAuth via the Codex CLI client) ---
+ // Raw tokens are never exposed over IPC — the renderer only sees identity
+ // and connection state.
+ 'chatgpt:getStatus': {
+ req: z.null(),
+ res: z.object({
+ signedIn: z.boolean(),
+ email: z.string().optional(),
+ accountId: z.string().optional(),
+ }),
+ },
+ // Resolves when the browser flow settles (success, denial, timeout, port
+ // busy, exchange failure, cancellation) — same shape as getStatus plus an
+ // error string; `cancelled` marks expected teardown (no error toast).
+ 'chatgpt:signIn': {
+ req: z.null(),
+ res: z.object({
+ signedIn: z.boolean(),
+ email: z.string().optional(),
+ accountId: z.string().optional(),
+ cancelled: z.boolean().optional(),
+ error: z.string().optional(),
+ }),
+ },
+ // Abort the pending sign-in attempt: stops the loopback server and settles
+ // the in-flight chatgpt:signIn with a cancelled outcome. No-op when idle.
+ 'chatgpt:cancelSignIn': {
+ req: z.null(),
+ res: z.object({
+ success: z.boolean(),
+ }),
+ },
+ 'chatgpt:signOut': {
+ req: z.null(),
+ res: z.object({
+ success: z.boolean(),
+ }),
+ },
+ // Push event (main → renderer): ChatGPT sign-in state changed. Model
+ // pickers listen and refresh — subscription models appear/disappear with
+ // the session.
+ 'chatgpt:statusChanged': {
+ req: z.object({
+ signedIn: z.boolean(),
+ }),
+ res: z.null(),
+ },
'app:openUrl': {
req: z.object({
url: z.string(),
diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts
index 5ed03104..f929491f 100644
--- a/apps/x/packages/shared/src/models.ts
+++ b/apps/x/packages/shared/src/models.ts
@@ -9,7 +9,10 @@ import { z } from "zod";
export const ReasoningEffort = z.enum(["low", "medium", "high"]);
export const LlmProvider = z.object({
- flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat"]),
+ // "rowboat" (signed-in gateway) and "codex" (ChatGPT subscription via
+ // "Sign in with ChatGPT") are credential-less flavors: they never appear
+ // in models.json's providers map — auth lives in their own token stores.
+ flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat", "codex"]),
apiKey: z.string().optional(),
baseURL: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),