mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
Merge pull request #753 from rowboatlabs/feat/chatgpt-oauth
feat: use your ChatGPT subscription — sign in with ChatGPT + Codex subscription models in the composer
This commit is contained in:
commit
5ba7918bb7
18 changed files with 1923 additions and 28 deletions
|
|
@ -20,9 +20,46 @@ export interface AuthServerResult {
|
|||
port: number;
|
||||
}
|
||||
|
||||
interface CallbackHandlingOpts {
|
||||
callbackPath: string;
|
||||
/** Invoked when the provider redirects back with an `error` param. */
|
||||
onError?: (error: string) => void;
|
||||
/**
|
||||
* Gatekeeper run BEFORE the error/callback handling. Return a message to
|
||||
* reject the request with a polite close-this-tab error page — without
|
||||
* invoking onCallback/onError. Lets a caller drop stale callbacks (e.g. the
|
||||
* browser tab of a cancelled sign-in attempt carrying an old `state`)
|
||||
* without disturbing the live flow.
|
||||
*/
|
||||
validateCallback?: (url: URL) => string | null;
|
||||
}
|
||||
|
||||
function renderErrorPage(res: import('http').ServerResponse, message: string): void {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth Error</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
.error { color: #d32f2f; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="error">Authorization Failed</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
function tryBindPort(
|
||||
port: number,
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>,
|
||||
opts: CallbackHandlingOpts,
|
||||
): Promise<AuthServerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
|
|
@ -34,29 +71,24 @@ function tryBindPort(
|
|||
|
||||
const url = new URL(req.url, `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === OAUTH_CALLBACK_PATH) {
|
||||
if (url.pathname === opts.callbackPath) {
|
||||
// Gatekeeper first: stale/foreign requests must not reach onError or
|
||||
// onCallback (a stale tab's redirect must never settle a live flow).
|
||||
const rejection = opts.validateCallback?.(url) ?? null;
|
||||
if (rejection) {
|
||||
renderErrorPage(res, rejection);
|
||||
return;
|
||||
}
|
||||
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth Error</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
.error { color: #d32f2f; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="error">Authorization Failed</h1>
|
||||
<p>Error: ${escapeHtml(error)}</p>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
// Surface the provider error (e.g. access_denied when the user
|
||||
// cancels consent) so the caller can settle its flow instead of
|
||||
// waiting for the timeout. Callers that don't opt in keep the old
|
||||
// behaviour: the error page renders and the flow times out.
|
||||
opts.onError?.(error);
|
||||
renderErrorPage(res, `Error: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -117,18 +149,29 @@ function tryBindPort(
|
|||
* through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling
|
||||
* both EADDRINUSE and EACCES (the latter is common on Windows when
|
||||
* Hyper-V/WSL2 reserve the port).
|
||||
*
|
||||
* `callbackPath` overrides the served path for providers whose registered
|
||||
* redirect URI differs (ChatGPT/Codex uses /auth/callback). `onError` is
|
||||
* invoked when the provider redirects back with an `error` param (e.g.
|
||||
* access_denied), letting the caller settle instead of waiting for timeout.
|
||||
* `validateCallback` runs before both — see CallbackHandlingOpts.
|
||||
*/
|
||||
export async function createAuthServer(
|
||||
port: number = DEFAULT_PORT,
|
||||
onCallback: (callbackUrl: URL) => void | Promise<void>,
|
||||
opts: { fallback?: boolean } = {},
|
||||
opts: { fallback?: boolean } & Partial<CallbackHandlingOpts> = {},
|
||||
): Promise<AuthServerResult> {
|
||||
const fallback = opts.fallback === true;
|
||||
const handlingOpts: CallbackHandlingOpts = {
|
||||
callbackPath: opts.callbackPath ?? OAUTH_CALLBACK_PATH,
|
||||
onError: opts.onError,
|
||||
validateCallback: opts.validateCallback,
|
||||
};
|
||||
const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port;
|
||||
|
||||
for (let p = port; p <= limit; p++) {
|
||||
try {
|
||||
return await tryBindPort(p, onCallback);
|
||||
return await tryBindPort(p, onCallback, handlingOpts);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) {
|
||||
|
|
|
|||
247
apps/x/apps/main/src/chatgpt-signin.ts
Normal file
247
apps/x/apps/main/src/chatgpt-signin.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { shell } from 'electron';
|
||||
import type { Server } from 'http';
|
||||
import { createAuthServer } from './auth-server.js';
|
||||
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||
import { exchangeChatGPTCode, getChatGPTStatus } from '@x/core/dist/auth/chatgpt-auth.js';
|
||||
import {
|
||||
CHATGPT_AUTHORIZE_URL,
|
||||
CHATGPT_CALLBACK_PATH,
|
||||
CHATGPT_CALLBACK_PORT,
|
||||
CHATGPT_CLIENT_ID,
|
||||
CHATGPT_EXTRA_AUTHORIZE_PARAMS,
|
||||
CHATGPT_REDIRECT_URI,
|
||||
CHATGPT_SCOPES,
|
||||
} from '@x/core/dist/auth/chatgpt-constants.js';
|
||||
|
||||
// Interactive "Sign in with ChatGPT" flow (OAuth 2.0 + PKCE, Codex CLI client
|
||||
// — see chatgpt-constants.ts). Orchestration only: PKCE/state generation and
|
||||
// all token-endpoint traffic + storage live in core; this module owns the
|
||||
// system browser, the loopback callback server on 127.0.0.1:1455, and flow
|
||||
// lifecycle. The port is FIXED — the redirect URI is pre-registered at OpenAI
|
||||
// for the Codex client id, so there is no scan-to-next-port fallback.
|
||||
|
||||
export type ChatGPTSignInResult = {
|
||||
signedIn: boolean;
|
||||
email?: string;
|
||||
accountId?: string;
|
||||
/** True when the attempt was cancelled (Cancel button or superseded). */
|
||||
cancelled?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Generous, mirrors the Google flow's abandoned-flow cleanup ceiling. */
|
||||
const SIGN_IN_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
type ActiveAttempt = {
|
||||
promise: Promise<ChatGPTSignInResult>;
|
||||
/**
|
||||
* Settle the attempt with a cancelled outcome. Resolves once the loopback
|
||||
* server has fully closed (listener + keep-alive connections), so a
|
||||
* follow-up attempt can rebind 1455 immediately.
|
||||
*/
|
||||
cancel: (reason: string) => Promise<void>;
|
||||
};
|
||||
|
||||
let activeAttempt: ActiveAttempt | null = null;
|
||||
|
||||
/**
|
||||
* Start a sign-in attempt. If one is already pending it is stale by
|
||||
* definition — the user is clicking Sign In again precisely because no
|
||||
* browser flow is visibly in progress (e.g. they closed the tab and hit
|
||||
* Cancel) — so cancel it and start FRESH (new PKCE verifier/state, new
|
||||
* loopback server, new browser window). Awaiting the cancel preserves the
|
||||
* one-server-at-a-time invariant: 1455 is fully released before rebinding.
|
||||
*/
|
||||
export async function signInWithChatGPT(): Promise<ChatGPTSignInResult> {
|
||||
if (activeAttempt) {
|
||||
const stale = activeAttempt;
|
||||
activeAttempt = null;
|
||||
console.log('[ChatGPTAuth] Cancelling stale sign-in attempt before starting a new one');
|
||||
await stale.cancel('Superseded by a new sign-in attempt.');
|
||||
}
|
||||
|
||||
const attempt = startAttempt();
|
||||
activeAttempt = attempt;
|
||||
void attempt.promise.finally(() => {
|
||||
if (activeAttempt === attempt) activeAttempt = null;
|
||||
});
|
||||
return attempt.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the pending attempt (renderer Cancel button): stops the loopback
|
||||
* server, clears pending state, settles the in-flight signIn promise with a
|
||||
* cancelled outcome. No-op when nothing is pending. Never touches stored
|
||||
* tokens — after cancel, chatgpt:getStatus reports signed-out (unless an
|
||||
* earlier sign-in already completed).
|
||||
*/
|
||||
export async function cancelChatGPTSignIn(): Promise<void> {
|
||||
const attempt = activeAttempt;
|
||||
if (!attempt) return;
|
||||
activeAttempt = null;
|
||||
await attempt.cancel('Sign-in cancelled.');
|
||||
}
|
||||
|
||||
/**
|
||||
* One sign-in attempt. The returned promise always RESOLVES (never rejects),
|
||||
* and every exit path — success, denial, timeout, port busy, exchange
|
||||
* failure, cancellation — tears down the loopback server and the timeout
|
||||
* exactly once via the settle-once `finish`.
|
||||
*/
|
||||
function startAttempt(): ActiveAttempt {
|
||||
let settle!: (result: ChatGPTSignInResult) => void;
|
||||
const promise = new Promise<ChatGPTSignInResult>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
let server: Server | null = null;
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
let serverClosed: Promise<void> | null = null;
|
||||
|
||||
// Close the listening socket AND any keep-alive connections (the browser
|
||||
// holds one open after the callback response) so 1455 frees immediately.
|
||||
const closeServer = (): Promise<void> => {
|
||||
if (serverClosed) return serverClosed;
|
||||
const s = server;
|
||||
server = null;
|
||||
serverClosed = !s
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => {
|
||||
s.close(() => resolve());
|
||||
s.closeAllConnections();
|
||||
});
|
||||
return serverClosed;
|
||||
};
|
||||
|
||||
const finish = (result: ChatGPTSignInResult): Promise<void> => {
|
||||
if (settled) return serverClosed ?? Promise.resolve();
|
||||
settled = true;
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
const closed = closeServer();
|
||||
if (!result.signedIn) {
|
||||
console.log(`[ChatGPTAuth] Sign-in did not complete: ${result.error ?? 'unknown'}`);
|
||||
}
|
||||
settle(result);
|
||||
return closed;
|
||||
};
|
||||
|
||||
const cancel = (reason: string): Promise<void> =>
|
||||
finish({ signedIn: false, cancelled: true, error: reason });
|
||||
|
||||
void run();
|
||||
return { promise, cancel };
|
||||
|
||||
async function run(): Promise<void> {
|
||||
console.log('[ChatGPTAuth] Starting sign-in flow...');
|
||||
try {
|
||||
const { verifier, challenge } = await oauthClient.generatePKCE();
|
||||
const state = oauthClient.generateState();
|
||||
if (settled) return; // cancelled while generating PKCE — nothing bound yet
|
||||
|
||||
// Guard against duplicate callbacks (browser may send multiple requests).
|
||||
let callbackHandled = false;
|
||||
const onCallback = async (callbackUrl: URL) => {
|
||||
if (settled || callbackHandled) return;
|
||||
callbackHandled = true;
|
||||
try {
|
||||
// State already verified by validateCallback below.
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
if (!code) {
|
||||
void finish({ signedIn: false, error: 'Sign-in failed: callback is missing the authorization code.' });
|
||||
return;
|
||||
}
|
||||
// Exchange + persistence live in core (never log token values).
|
||||
await exchangeChatGPTCode(code, verifier);
|
||||
const status = await getChatGPTStatus();
|
||||
console.log('[ChatGPTAuth] Sign-in complete');
|
||||
void finish({ ...status });
|
||||
} catch (error) {
|
||||
console.error('[ChatGPTAuth] Token exchange failed:', error);
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error instanceof Error ? error.message : 'Token exchange failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Bind the loopback server FIRST so a busy port fails fast, before any
|
||||
// browser tab opens. Fixed port — createAuthServer's no-fallback error
|
||||
// message tells the user to free the port.
|
||||
let boundServer: Server;
|
||||
try {
|
||||
({ server: boundServer } = await createAuthServer(CHATGPT_CALLBACK_PORT, onCallback, {
|
||||
callbackPath: CHATGPT_CALLBACK_PATH,
|
||||
onError: (error) => {
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error === 'access_denied'
|
||||
? 'Sign-in was cancelled in the browser.'
|
||||
: `Sign-in failed: ${error}`,
|
||||
});
|
||||
},
|
||||
// Stale callbacks — a tab left over from an earlier, cancelled
|
||||
// attempt carries that attempt's `state` — get a polite
|
||||
// close-this-tab page and never reach onError/onCallback, so they
|
||||
// can neither complete sign-in nor settle the live attempt.
|
||||
validateCallback: (url) => {
|
||||
if (settled) {
|
||||
return 'This sign-in attempt is no longer active. Close this tab and try again from Rowboat.';
|
||||
}
|
||||
if (url.searchParams.get('state') !== state) {
|
||||
return 'This sign-in link has expired. Close this tab and try again from Rowboat.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to start the sign-in callback server',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (settled) {
|
||||
// Cancelled while the bind was in flight — release the port we just
|
||||
// grabbed (finish() already ran with no server to close).
|
||||
boundServer.closeAllConnections();
|
||||
boundServer.close();
|
||||
return;
|
||||
}
|
||||
server = boundServer;
|
||||
|
||||
timeoutHandle = setTimeout(() => {
|
||||
void finish({ signedIn: false, error: 'Sign-in timed out. Please try again.' });
|
||||
}, SIGN_IN_TIMEOUT_MS);
|
||||
|
||||
const authUrl = new URL(CHATGPT_AUTHORIZE_URL);
|
||||
authUrl.search = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: CHATGPT_CLIENT_ID,
|
||||
redirect_uri: CHATGPT_REDIRECT_URI,
|
||||
scope: CHATGPT_SCOPES.join(' '),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
...CHATGPT_EXTRA_AUTHORIZE_PARAMS,
|
||||
}).toString();
|
||||
|
||||
try {
|
||||
// System browser: shares the user's existing ChatGPT session cookies.
|
||||
await shell.openExternal(authUrl.toString());
|
||||
} catch (error) {
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error instanceof Error ? `Failed to open browser: ${error.message}` : 'Failed to open browser',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChatGPTAuth] Sign-in flow error:', error);
|
||||
void finish({
|
||||
signedIn: false,
|
||||
error: error instanceof Error ? error.message : 'Sign-in failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,9 @@ import { isSignedIn } from '@x/core/dist/account/account.js';
|
|||
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
||||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { getChatGPTStatus, signOutChatGPT } from '@x/core/dist/auth/chatgpt-auth.js';
|
||||
import { listCodexModels } from '@x/core/dist/models/codex.js';
|
||||
import { signInWithChatGPT, cancelChatGPTSignIn } from './chatgpt-signin.js';
|
||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
|
||||
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
|
||||
|
|
@ -1232,10 +1235,21 @@ export function setupIpcHandlers() {
|
|||
}
|
||||
},
|
||||
'models:list': async () => {
|
||||
if (await isSignedIn()) {
|
||||
return await listGatewayModels();
|
||||
const base = (await isSignedIn())
|
||||
? await listGatewayModels()
|
||||
: await listOnboardingModels();
|
||||
// ChatGPT-subscription (codex) models are additive and independent of
|
||||
// Rowboat sign-in; their failure must never break the main list.
|
||||
try {
|
||||
const chatgpt = await getChatGPTStatus();
|
||||
if (chatgpt.signedIn) {
|
||||
const codex = await listCodexModels();
|
||||
return { providers: [...base.providers, ...codex.providers] };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Codex] Listing subscription models failed:', error);
|
||||
}
|
||||
return await listOnboardingModels();
|
||||
return base;
|
||||
},
|
||||
'models:test': async (_event, args) => {
|
||||
return await testModelConnection(args.provider, args.model);
|
||||
|
|
@ -1285,6 +1299,32 @@ export function setupIpcHandlers() {
|
|||
const config = await repo.getClientFacingConfig();
|
||||
return { config };
|
||||
},
|
||||
'chatgpt:getStatus': async () => {
|
||||
return await getChatGPTStatus();
|
||||
},
|
||||
'chatgpt:signIn': async () => {
|
||||
const result = await signInWithChatGPT();
|
||||
if (result.signedIn) {
|
||||
// Model lists gate on sign-in state (composer picker, models:list) —
|
||||
// push the change so they refresh without polling.
|
||||
broadcastToWindows('chatgpt:statusChanged', { signedIn: true });
|
||||
}
|
||||
return result;
|
||||
},
|
||||
'chatgpt:cancelSignIn': async () => {
|
||||
await cancelChatGPTSignIn();
|
||||
return { success: true };
|
||||
},
|
||||
'chatgpt:signOut': async () => {
|
||||
try {
|
||||
await signOutChatGPT();
|
||||
broadcastToWindows('chatgpt:statusChanged', { signedIn: false });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[ChatGPTAuth] Sign-out failed:', error);
|
||||
return { success: false };
|
||||
}
|
||||
},
|
||||
'account:getRowboat': async () => {
|
||||
const signedIn = await isSignedIn();
|
||||
if (!signedIn) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/runtime/asse
|
|||
import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js";
|
||||
import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js";
|
||||
import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js";
|
||||
import { setTokenCipher as setChatGPTTokenCipher } from "@x/core/dist/auth/chatgpt-auth.js";
|
||||
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
|
||||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||
import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js";
|
||||
|
|
@ -498,6 +499,12 @@ app.whenReady().then(async () => {
|
|||
encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
|
||||
decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
|
||||
});
|
||||
// ChatGPT subscription tokens at rest: same keychain-backed cipher.
|
||||
setChatGPTTokenCipher({
|
||||
isAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||
encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
|
||||
decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
|
||||
});
|
||||
initAppsServer().catch((error) => {
|
||||
console.error('[Apps] Failed to start:', error);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -94,9 +94,12 @@ const providerDisplayNames: Record<string, string> = {
|
|||
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() }
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 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" && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
ChatGPT Subscription
|
||||
</span>
|
||||
{chatgpt.status.signedIn ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600 min-w-0">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
Connected as {chatgpt.status.email ?? "your ChatGPT account"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={chatgpt.signOut}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
) : chatgpt.isSigningIn ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Waiting for browser…
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.cancelSignIn}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Use your ChatGPT Plus/Pro subscription
|
||||
</span>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.signIn}>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL */}
|
||||
{showBaseURL && (
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
114
apps/x/apps/renderer/src/hooks/useChatGPT.ts
Normal file
114
apps/x/apps/renderer/src/hooks/useChatGPT.ts
Normal file
|
|
@ -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<ChatGPTStatus>({ 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,
|
||||
};
|
||||
}
|
||||
352
apps/x/packages/core/src/auth/chatgpt-auth.test.ts
Normal file
352
apps/x/packages/core/src/auth/chatgpt-auth.test.ts
Normal file
|
|
@ -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, unknown>): string {
|
||||
const b64 = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
return `${b64({ alg: 'none' })}.${b64(claims)}.sig`;
|
||||
}
|
||||
|
||||
function makeIdToken(overrides: Record<string, unknown> = {}): string {
|
||||
return makeJwt({
|
||||
email: 'user@example.com',
|
||||
[CHATGPT_AUTH_CLAIM_NAMESPACE]: { chatgpt_account_id: 'acct_123' },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function makeAccessToken(expiresInSeconds: number, extra: Record<string, unknown> = {}): 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<string, unknown> {
|
||||
return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
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<string, string>)['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<string, string>;
|
||||
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<string, string>;
|
||||
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<Response>((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 });
|
||||
});
|
||||
});
|
||||
389
apps/x/packages/core/src/auth/chatgpt-auth.ts
Normal file
389
apps/x/packages/core/src/auth/chatgpt-auth.ts
Normal file
|
|
@ -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<string, unknown> | 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<string, unknown>
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function claimString(source: unknown, key: string): string | undefined {
|
||||
if (source !== null && typeof source === 'object') {
|
||||
const value = (source as Record<string, unknown>)[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<string | undefined>): { 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<StoredChatGPTAuth | null> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(AUTH_FILE, 'utf-8')) as StoredChatGPTAuth;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAuth(auth: StoredChatGPTAuth): Promise<void> {
|
||||
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<void> {
|
||||
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<TokenMaterial | null> {
|
||||
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<string> | null = null;
|
||||
|
||||
async function performRefresh(refreshToken: string): Promise<string> {
|
||||
// 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<string> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
97
apps/x/packages/core/src/auth/chatgpt-constants.ts
Normal file
97
apps/x/packages/core/src/auth/chatgpt-constants.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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';
|
||||
165
apps/x/packages/core/src/models/codex.test.ts
Normal file
165
apps/x/packages/core/src/models/codex.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
303
apps/x/packages/core/src/models/codex.ts
Normal file
303
apps/x/packages/core/src/models/codex.ts
Normal file
|
|
@ -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<string> | null = null;
|
||||
function codexClientVersion(): Promise<string> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
} 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<string, unknown>
|
||||
: {};
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
|
@ -78,6 +78,11 @@ export async function resolveProviderConfig(name: string): Promise<z.infer<typeo
|
|||
if (name === "rowboat") {
|
||||
return { flavor: "rowboat" };
|
||||
}
|
||||
if (name === "codex") {
|
||||
// ChatGPT subscription: auth lives in chatgpt-auth.json (core auth
|
||||
// layer), never in models.json — which may not exist at all.
|
||||
return { flavor: "codex" };
|
||||
}
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
const cfg = await repo.getConfig();
|
||||
const entry = cfg.providers?.[name];
|
||||
|
|
|
|||
|
|
@ -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<typeof Provider>): ProviderV4 {
|
|||
}) as unknown as ProviderV4;
|
||||
case "rowboat":
|
||||
return getGatewayProvider();
|
||||
case "codex":
|
||||
return getCodexProvider();
|
||||
default:
|
||||
throw new Error(`Unsupported provider flavor: ${config.flavor}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue