From bbb2c987e58a0c3cf51cf1e72d474697bad731a1 Mon Sep 17 00:00:00 2001 From: jnjpng Date: Thu, 15 Jan 2026 13:57:39 -0800 Subject: [PATCH] feat: replace /connect claude with /connect codex for OpenAI OAuth (#527) Co-authored-by: Letta --- src/agent/client.ts | 6 +- src/agent/modify.ts | 11 +- src/auth/anthropic-oauth.ts | 235 ---------- src/auth/openai-oauth.ts | 390 ++++++++++++++++ src/cli/App.tsx | 80 +--- src/cli/commands/connect.ts | 409 ++++++++++------- src/cli/commands/registry.ts | 4 +- src/cli/components/InputRich.tsx | 14 +- src/cli/components/ModelSelector.tsx | 21 +- src/cli/components/OAuthCodeDialog.tsx | 419 ------------------ src/models.json | 119 +++-- ...c-provider.ts => openai-codex-provider.ts} | 149 ++++--- src/settings-manager.ts | 80 +--- 13 files changed, 858 insertions(+), 1079 deletions(-) delete mode 100644 src/auth/anthropic-oauth.ts create mode 100644 src/auth/openai-oauth.ts delete mode 100644 src/cli/components/OAuthCodeDialog.tsx rename src/providers/{anthropic-provider.ts => openai-codex-provider.ts} (54%) diff --git a/src/agent/client.ts b/src/agent/client.ts index eab8bcc..9f29f76 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -2,7 +2,6 @@ import { hostname } from "node:os"; import Letta from "@letta-ai/letta-client"; import packageJson from "../../package.json"; import { LETTA_CLOUD_API_URL, refreshAccessToken } from "../auth/oauth"; -import { ensureAnthropicProviderToken } from "../providers/anthropic-provider"; import { settingsManager } from "../settings-manager"; import { createTimingFetch, isTimingsEnabled } from "../utils/timing"; @@ -76,9 +75,8 @@ export async function getClient() { process.exit(1); } - // Ensure Anthropic OAuth token is valid and provider is updated - // This checks if token is expired, refreshes it, and updates the provider - await ensureAnthropicProviderToken(); + // Note: OpenAI Codex OAuth token refresh is handled by the Letta backend + // when using the chatgpt_oauth provider type return new Letta({ apiKey, diff --git a/src/agent/modify.ts b/src/agent/modify.ts index 75bef83..ce6a57a 100644 --- a/src/agent/modify.ts +++ b/src/agent/modify.ts @@ -8,7 +8,7 @@ import type { OpenAIModelSettings, } from "@letta-ai/letta-client/resources/agents/agents"; import type { LlmConfig } from "@letta-ai/letta-client/resources/models/models"; -import { ANTHROPIC_PROVIDER_NAME } from "../providers/anthropic-provider"; +import { OPENAI_CODEX_PROVIDER_NAME } from "../providers/openai-codex-provider"; import { getClient } from "./client"; type ModelSettings = @@ -25,11 +25,14 @@ function buildModelSettings( modelHandle: string, updateArgs?: Record, ): ModelSettings { - const isOpenAI = modelHandle.startsWith("openai/"); - // Include our custom Anthropic OAuth provider (claude-pro-max) + // Include our custom OpenAI Codex OAuth provider (chatgpt-plus-pro) + const isOpenAI = + modelHandle.startsWith("openai/") || + modelHandle.startsWith(`${OPENAI_CODEX_PROVIDER_NAME}/`); + // Include legacy custom Anthropic OAuth provider (claude-pro-max) const isAnthropic = modelHandle.startsWith("anthropic/") || - modelHandle.startsWith(`${ANTHROPIC_PROVIDER_NAME}/`); + modelHandle.startsWith("claude-pro-max/"); const isZai = modelHandle.startsWith("zai/"); const isGoogleAI = modelHandle.startsWith("google_ai/"); const isGoogleVertex = modelHandle.startsWith("google_vertex/"); diff --git a/src/auth/anthropic-oauth.ts b/src/auth/anthropic-oauth.ts deleted file mode 100644 index 39f900c..0000000 --- a/src/auth/anthropic-oauth.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * OAuth 2.0 utilities for Anthropic authentication - * Uses Authorization Code Flow with PKCE - */ - -export const ANTHROPIC_OAUTH_CONFIG = { - clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", - authorizationUrl: "https://claude.ai/oauth/authorize", - tokenUrl: "https://console.anthropic.com/v1/oauth/token", - redirectUri: "https://console.anthropic.com/oauth/code/callback", - scope: "org:create_api_key user:profile user:inference", -} as const; - -export interface AnthropicTokens { - access_token: string; - refresh_token?: string; - token_type: string; - expires_in: number; - scope?: string; -} - -export interface OAuthError { - error: string; - error_description?: string; -} - -/** - * Generate PKCE code verifier (43-128 characters of unreserved URI characters) - */ -export function generateCodeVerifier(): string { - const array = new Uint8Array(32); - crypto.getRandomValues(array); - return base64UrlEncode(array); -} - -/** - * Generate PKCE code challenge from verifier using SHA-256 - */ -export async function generateCodeChallenge(verifier: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(verifier); - const digest = await crypto.subtle.digest("SHA-256", data); - return base64UrlEncode(new Uint8Array(digest)); -} - -/** - * Generate cryptographically secure state parameter (32-byte hex) - */ -export function generateState(): string { - const array = new Uint8Array(32); - crypto.getRandomValues(array); - return Array.from(array) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -/** - * Base64 URL encode (RFC 4648) - */ -function base64UrlEncode(buffer: Uint8Array): string { - const base64 = btoa(String.fromCharCode(...buffer)); - return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -/** - * Generate PKCE code verifier and challenge - */ -export async function generatePKCE(): Promise<{ - codeVerifier: string; - codeChallenge: string; -}> { - const codeVerifier = generateCodeVerifier(); - const codeChallenge = await generateCodeChallenge(codeVerifier); - return { codeVerifier, codeChallenge }; -} - -/** - * Start OAuth flow - returns authorization URL and PKCE values - */ -export async function startAnthropicOAuth(): Promise<{ - authorizationUrl: string; - state: string; - codeVerifier: string; -}> { - const state = generateState(); - const { codeVerifier, codeChallenge } = await generatePKCE(); - - const params = new URLSearchParams({ - response_type: "code", - client_id: ANTHROPIC_OAUTH_CONFIG.clientId, - redirect_uri: ANTHROPIC_OAUTH_CONFIG.redirectUri, - scope: ANTHROPIC_OAUTH_CONFIG.scope, - state, - code_challenge: codeChallenge, - code_challenge_method: "S256", - }); - - const authorizationUrl = `${ANTHROPIC_OAUTH_CONFIG.authorizationUrl}?${params.toString()}`; - - return { - authorizationUrl, - state, - codeVerifier, - }; -} - -/** - * Exchange authorization code for tokens - */ -export async function exchangeCodeForTokens( - code: string, - codeVerifier: string, - state: string, -): Promise { - const response = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - grant_type: "authorization_code", - client_id: ANTHROPIC_OAUTH_CONFIG.clientId, - code, - state, - redirect_uri: ANTHROPIC_OAUTH_CONFIG.redirectUri, - code_verifier: codeVerifier, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - // Show full response for debugging - throw new Error( - `Failed to exchange code for tokens (HTTP ${response.status}): ${errorText}`, - ); - } - - return (await response.json()) as AnthropicTokens; -} - -/** - * Refresh an access token using a refresh token - */ -export async function refreshAnthropicToken( - refreshToken: string, -): Promise { - const response = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - client_id: ANTHROPIC_OAUTH_CONFIG.clientId, - refresh_token: refreshToken, - }), - }); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: "unknown_error", - error_description: `HTTP ${response.status}`, - }))) as OAuthError; - throw new Error( - `Failed to refresh access token: ${error.error_description || error.error}`, - ); - } - - return (await response.json()) as AnthropicTokens; -} - -/** - * Validate credentials by making a test API call - * OAuth tokens require the anthropic-beta header - */ -export async function validateAnthropicCredentials( - accessToken: string, -): Promise { - try { - // Use the models endpoint to validate the token - const response = await fetch("https://api.anthropic.com/v1/models", { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "anthropic-version": "2023-06-01", - "anthropic-beta": "oauth-2025-04-20", - }, - }); - - return response.ok; - } catch { - return false; - } -} - -/** - * Get a valid Anthropic access token, refreshing if necessary - * Returns null if no OAuth tokens are configured - */ -export async function getAnthropicAccessToken(): Promise { - // Lazy import to avoid circular dependencies - const { settingsManager } = await import("../settings-manager"); - - const tokens = settingsManager.getAnthropicTokens(); - if (!tokens) { - return null; - } - - // Check if token is expired or about to expire (within 5 minutes) - const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000; - if (tokens.expires_at < fiveMinutesFromNow && tokens.refresh_token) { - try { - const newTokens = await refreshAnthropicToken(tokens.refresh_token); - settingsManager.storeAnthropicTokens(newTokens); - return newTokens.access_token; - } catch (error) { - console.error("Failed to refresh Anthropic access token:", error); - // Return existing token even if refresh failed - it might still work - return tokens.access_token; - } - } - - return tokens.access_token; -} - -/** - * Check if Anthropic OAuth is configured and valid - */ -export async function hasValidAnthropicAuth(): Promise { - const token = await getAnthropicAccessToken(); - if (!token) { - return false; - } - return validateAnthropicCredentials(token); -} diff --git a/src/auth/openai-oauth.ts b/src/auth/openai-oauth.ts new file mode 100644 index 0000000..3db539e --- /dev/null +++ b/src/auth/openai-oauth.ts @@ -0,0 +1,390 @@ +/** + * OAuth 2.0 utilities for OpenAI Codex authentication + * Uses Authorization Code Flow with PKCE and local callback server + * Compatible with Codex CLI authentication flow + */ + +import http from "node:http"; + +export const OPENAI_OAUTH_CONFIG = { + clientId: "app_EMoamEEZ73f0CkXaXp7hrann", + authorizationUrl: "https://auth.openai.com/oauth/authorize", + tokenUrl: "https://auth.openai.com/oauth/token", + defaultPort: 1455, + callbackPath: "/auth/callback", + scope: "openid profile email offline_access", +} as const; + +export interface OpenAITokens { + access_token: string; + id_token: string; + refresh_token?: string; + token_type: string; + expires_in: number; +} + +export interface OAuthError { + error: string; + error_description?: string; +} + +export interface OAuthCallbackResult { + code: string; + state: string; +} + +/** + * Render a minimal OAuth callback page with ASCII art + */ +function renderOAuthPage(options: { + success: boolean; + title: string; + message: string; + detail?: string; + autoClose?: boolean; +}): string { + const { title, message, autoClose } = options; + + // ASCII art logo (escaped for HTML) + const asciiLogo = ` ██████ ██╗ ███████╗████████╗████████╗ █████╗ +██ ██ ██║ ██╔════╝╚══██╔══╝╚══██╔══╝██╔══██╗ +██ ▇▇ ██ ██║ █████╗ ██║ ██║ ███████║ +██ ██ ██║ ██╔══╝ ██║ ██║ ██╔══██║ + ██████ ███████╗███████╗ ██║ ██║ ██║ ██║ + ╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝`; + + return ` + + + + + ${title} - Letta Code + + + +
+
${asciiLogo}
+

${title}

+

${message}

+
+ ${autoClose ? `` : ""} + +`; +} + +/** + * Generate PKCE code verifier (43-128 characters of unreserved URI characters) + */ +export function generateCodeVerifier(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64UrlEncode(array); +} + +/** + * Generate PKCE code challenge from verifier using SHA-256 + */ +export async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest("SHA-256", data); + return base64UrlEncode(new Uint8Array(digest)); +} + +/** + * Generate cryptographically secure state parameter (32-byte hex) + */ +export function generateState(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Base64 URL encode (RFC 4648) + */ +function base64UrlEncode(buffer: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...buffer)); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * Decode JWT payload (no signature verification - for local extraction only) + */ +function decodeJwtPayload(token: string): Record { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("Invalid JWT format"); + } + const payload = parts[1]; + if (!payload) { + throw new Error("Missing JWT payload"); + } + // Handle base64url encoding + const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const decoded = atob(padded); + return JSON.parse(decoded); +} + +/** + * Extract ChatGPT Account ID from access token JWT + * The account ID is in the custom claim: https://api.openai.com/auth.chatgpt_account_id + */ +export function extractAccountIdFromToken(accessToken: string): string { + try { + const payload = decodeJwtPayload(accessToken); + // The account ID is in the custom claim path + const authClaim = payload["https://api.openai.com/auth"] as + | Record + | undefined; + if (authClaim && typeof authClaim.chatgpt_account_id === "string") { + return authClaim.chatgpt_account_id; + } + throw new Error("chatgpt_account_id not found in token claims"); + } catch (error) { + throw new Error( + `Failed to extract account ID from token: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Generate PKCE code verifier and challenge + */ +export async function generatePKCE(): Promise<{ + codeVerifier: string; + codeChallenge: string; +}> { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + return { codeVerifier, codeChallenge }; +} + +/** + * Start a local HTTP server to receive OAuth callback + * Returns a promise that resolves with the authorization code when received + */ +export function startLocalOAuthServer( + expectedState: string, + port = OPENAI_OAUTH_CONFIG.defaultPort, +): Promise<{ result: OAuthCallbackResult; server: http.Server }> { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url || "", `http://localhost:${port}`); + + if (url.pathname === OPENAI_OAUTH_CONFIG.callbackPath) { + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + if (error) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end( + renderOAuthPage({ + success: false, + title: "Authentication Failed", + message: `Error: ${error}`, + detail: errorDescription || undefined, + }), + ); + reject( + new Error(`OAuth error: ${error} - ${errorDescription || ""}`), + ); + return; + } + + if (!code || !state) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end( + renderOAuthPage({ + success: false, + title: "Authentication Failed", + message: "Missing authorization code or state parameter.", + }), + ); + reject(new Error("Missing authorization code or state parameter")); + return; + } + + if (state !== expectedState) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end( + renderOAuthPage({ + success: false, + title: "Authentication Failed", + message: + "State mismatch - the authorization may have been tampered with.", + }), + ); + reject( + new Error( + "State mismatch - the authorization may have been tampered with", + ), + ); + return; + } + + // Success! + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + renderOAuthPage({ + success: true, + title: "Authorization Successful", + message: "You can close this window and return to Letta Code.", + autoClose: true, + }), + ); + + resolve({ result: { code, state }, server }); + } else { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not found"); + } + }); + + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + reject( + new Error( + `Port ${port} is already in use. Please close any application using this port and try again.`, + ), + ); + } else { + reject(err); + } + }); + + server.listen(port, "127.0.0.1", () => { + // Server started successfully, waiting for callback + }); + + // Timeout after 5 minutes + setTimeout( + () => { + server.close(); + reject( + new Error("OAuth timeout - no callback received within 5 minutes"), + ); + }, + 5 * 60 * 1000, + ); + }); +} + +/** + * Start OAuth flow - returns authorization URL and PKCE values + * Also starts local server to receive callback + */ +export async function startOpenAIOAuth( + port = OPENAI_OAUTH_CONFIG.defaultPort, +): Promise<{ + authorizationUrl: string; + state: string; + codeVerifier: string; + redirectUri: string; +}> { + const state = generateState(); + const { codeVerifier, codeChallenge } = await generatePKCE(); + const redirectUri = `http://localhost:${port}${OPENAI_OAUTH_CONFIG.callbackPath}`; + + const params = new URLSearchParams({ + response_type: "code", + client_id: OPENAI_OAUTH_CONFIG.clientId, + redirect_uri: redirectUri, + scope: OPENAI_OAUTH_CONFIG.scope, + state, + code_challenge: codeChallenge, + code_challenge_method: "S256", + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + originator: "codex_cli_rs", + }); + + const authorizationUrl = `${OPENAI_OAUTH_CONFIG.authorizationUrl}?${params.toString()}`; + + return { + authorizationUrl, + state, + codeVerifier, + redirectUri, + }; +} + +/** + * Exchange authorization code for tokens + */ +export async function exchangeCodeForTokens( + code: string, + codeVerifier: string, + redirectUri: string, +): Promise { + const response = await fetch(OPENAI_OAUTH_CONFIG.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: OPENAI_OAUTH_CONFIG.clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to exchange code for tokens (HTTP ${response.status}): ${errorText}`, + ); + } + + return (await response.json()) as OpenAITokens; +} diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 14faa44..2bbe277 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -95,7 +95,6 @@ import { MemoryViewer } from "./components/MemoryViewer"; import { MessageSearch } from "./components/MessageSearch"; import { ModelSelector } from "./components/ModelSelector"; import { NewAgentDialog } from "./components/NewAgentDialog"; -import { OAuthCodeDialog } from "./components/OAuthCodeDialog"; import { PendingApprovalStub } from "./components/PendingApprovalStub"; import { PinDialog, validateAgentName } from "./components/PinDialog"; // QuestionDialog removed - now using InlineQuestionApproval @@ -821,13 +820,17 @@ export default function App({ | "new" | "mcp" | "help" - | "oauth" | null; const [activeOverlay, setActiveOverlay] = useState(null); const [feedbackPrefill, setFeedbackPrefill] = useState(""); + const [modelSelectorOptions, setModelSelectorOptions] = useState<{ + filterProvider?: string; + forceRefresh?: boolean; + }>({}); const closeOverlay = useCallback(() => { setActiveOverlay(null); setFeedbackPrefill(""); + setModelSelectorOptions({}); }, []); // Pin dialog state @@ -3469,6 +3472,7 @@ export default function App({ // Special handling for /model command - opens selector if (trimmed === "/model") { + setModelSelectorOptions({}); // Clear any filters from previous connection setActiveOverlay("model"); return { submitted: true }; } @@ -3556,37 +3560,22 @@ export default function App({ // Special handling for /connect command - OAuth connection if (msg.trim().startsWith("/connect")) { - const parts = msg.trim().split(/\s+/); - const provider = parts[1]?.toLowerCase(); - const hasCode = parts.length > 2; - - // Handle /connect zai - create zai-coding-plan provider - if (provider === "zai") { - const { handleConnectZai } = await import("./commands/connect"); - await handleConnectZai( - { - buffersRef, - refreshDerived, - setCommandRunning, - }, - msg, - ); - return { submitted: true }; - } - - // If no code provided and provider is claude, show the OAuth dialog - if (provider === "claude" && !hasCode) { - setActiveOverlay("oauth"); - return { submitted: true }; - } - - // Otherwise (with code or invalid provider), use existing handler + // Handle all /connect commands through the unified handler + // For codex: uses local OAuth server (no dialog needed) + // For zai: requires API key as argument const { handleConnect } = await import("./commands/connect"); await handleConnect( { buffersRef, refreshDerived, setCommandRunning, + onCodexConnected: () => { + setModelSelectorOptions({ + filterProvider: "chatgpt-plus-pro", + forceRefresh: true, + }); + setActiveOverlay("model"); + }, }, msg, ); @@ -6527,7 +6516,6 @@ DO NOT respond to these messages or otherwise consider them in your response unl const { env: _env, refreshToken: _refreshToken, - anthropicOAuth: _anthropicOAuth, ...safeSettings } = settings; @@ -7382,6 +7370,8 @@ Plan file path: ${planFilePath}`; currentModelId={currentModelId ?? undefined} onSelect={handleModelSelect} onCancel={closeOverlay} + filterProvider={modelSelectorOptions.filterProvider} + forceRefresh={modelSelectorOptions.forceRefresh} /> )} @@ -7755,40 +7745,6 @@ Plan file path: ${planFilePath}`; {/* Help Dialog - conditionally mounted as overlay */} {activeOverlay === "help" && } - {/* OAuth Code Dialog - for Claude OAuth connection */} - {activeOverlay === "oauth" && ( - { - closeOverlay(); - const cmdId = uid("cmd"); - buffersRef.current.byId.set(cmdId, { - kind: "command", - id: cmdId, - input: "/connect claude", - output: message, - phase: "finished", - success, - }); - buffersRef.current.order.push(cmdId); - refreshDerived(); - }} - onCancel={closeOverlay} - onModelSwitch={async (modelHandle: string) => { - const { updateAgentLLMConfig } = await import( - "../agent/modify" - ); - const { getModelUpdateArgs, getModelInfo } = await import( - "../agent/model" - ); - const updateArgs = getModelUpdateArgs(modelHandle); - await updateAgentLLMConfig(agentId, modelHandle, updateArgs); - // Update current model display - use model id for correct "(current)" indicator - const modelInfo = getModelInfo(modelHandle); - setCurrentModelId(modelInfo?.id || modelHandle); - }} - /> - )} - {/* New Agent Dialog - for naming new agent before creation */} {activeOverlay === "new" && ( void; setCommandRunning: (running: boolean) => void; + onCodexConnected?: () => void; // Callback to show model selector after successful connection } // Helper to add a command result to buffers @@ -85,13 +89,13 @@ function updateCommandResult( /** * Handle /connect command - * Usage: /connect claude [code] + * Usage: /connect codex * * Flow: - * 1. User runs `/connect claude` - opens browser for authorization - * 2. User authorizes on claude.ai, gets redirected to Anthropic's callback page - * 3. User copies the authorization code from the URL - * 4. User runs `/connect claude ` to complete the exchange + * 1. User runs `/connect codex` - starts local server and opens browser for authorization + * 2. User authorizes in browser, gets redirected back to local server + * 3. Server automatically exchanges code for tokens and API key + * 4. Provider is created and user sees success message */ export async function handleConnect( ctx: ConnectCommandContext, @@ -99,8 +103,6 @@ export async function handleConnect( ): Promise { const parts = msg.trim().split(/\s+/); const provider = parts[1]?.toLowerCase(); - // Join all remaining parts in case the code#state got split across lines - const authCode = parts.slice(2).join(""); // Optional authorization code // Validate provider argument if (!provider) { @@ -108,18 +110,18 @@ export async function handleConnect( ctx.buffersRef, ctx.refreshDerived, msg, - "Usage: /connect [options]\n\nAvailable providers:\n • claude - Connect via OAuth to authenticate without an API key\n • zai - Connect to Zai with your API key", + "Usage: /connect [options]\n\nAvailable providers:\n \u2022 codex - Connect via OAuth to authenticate with ChatGPT Plus/Pro\n \u2022 zai - Connect to Zai with your API key", false, ); return; } - if (provider !== "claude" && provider !== "zai") { + if (provider !== "codex" && provider !== "zai") { addCommandResult( ctx.buffersRef, ctx.refreshDerived, msg, - `Error: Unknown provider "${provider}"\n\nAvailable providers: claude, zai\nUsage: /connect [options]`, + `Error: Unknown provider "${provider}"\n\nAvailable providers: codex, zai\nUsage: /connect [options]`, false, ); return; @@ -131,28 +133,31 @@ export async function handleConnect( return; } - // If authorization code is provided, complete the OAuth flow - if (authCode && authCode.length > 0) { - await completeOAuthFlow(ctx, msg, authCode); - return; - } + // Handle /connect codex + await handleConnectCodex(ctx, msg); +} - // Check if already connected - if ( - settingsManager.hasAnthropicOAuth() && - !settingsManager.isAnthropicTokenExpired() - ) { +/** + * Handle /connect codex - OpenAI Codex OAuth with local server + */ +async function handleConnectCodex( + ctx: ConnectCommandContext, + msg: string, +): Promise { + // Check if already connected (provider exists on backend) + const existingProvider = await getOpenAICodexProvider(); + if (existingProvider) { addCommandResult( ctx.buffersRef, ctx.refreshDerived, msg, - "Already connected to Claude via OAuth.\n\nUse /disconnect to remove the current connection first.", + "Already connected to OpenAI Codex via OAuth.\n\nUse /disconnect codex to remove the current connection first.", false, ); return; } - // Start the OAuth flow (step 1) + // Start the OAuth flow ctx.setCommandRunning(true); // Show initial status @@ -167,20 +172,20 @@ export async function handleConnect( try { // 1. Check eligibility before starting OAuth flow - const eligibility = await checkAnthropicOAuthEligibility(); + const eligibility = await checkOpenAICodexEligibility(); if (!eligibility.eligible) { updateCommandResult( ctx.buffersRef, ctx.refreshDerived, cmdId, msg, - `✗ Claude OAuth requires a Pro or Enterprise plan\n\n` + + `\u2717 OpenAI Codex OAuth requires a Pro or Enterprise plan\n\n` + `This feature is only available for Letta Pro or Enterprise customers.\n` + `Current plan: ${eligibility.billing_tier}\n\n` + `To upgrade your plan, visit:\n\n` + ` https://app.letta.com/settings/organization/usage\n\n` + - `If you have an Anthropic API key, you can use it directly by setting:\n` + - ` export ANTHROPIC_API_KEY=your-key`, + `If you have an OpenAI API key, you can use it directly by setting:\n` + + ` export OPENAI_API_KEY=your-key`, false, "finished", ); @@ -188,18 +193,46 @@ export async function handleConnect( } // 2. Start OAuth flow - generate PKCE and authorization URL - const { authorizationUrl, state, codeVerifier } = - await startAnthropicOAuth(); + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + "Starting OAuth flow...\nA browser window will open for authorization.", + true, + "running", + ); - // 3. Store state for validation when user returns with code - settingsManager.storeOAuthState(state, codeVerifier, "anthropic"); + const { authorizationUrl, state, codeVerifier, redirectUri } = + await startOpenAIOAuth(OPENAI_OAUTH_CONFIG.defaultPort); - // 4. Try to open browser - let browserOpened = false; + // 3. Store state for validation + settingsManager.storeOAuthState(state, codeVerifier, redirectUri, "openai"); + + // 4. Start local server to receive callback + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `Starting local OAuth server on port ${OPENAI_OAUTH_CONFIG.defaultPort}...\n\n` + + `Opening browser for authorization...\n` + + `If the browser doesn't open automatically, visit:\n\n` + + `${authorizationUrl}`, + true, + "running", + ); + + // Start the server and wait for callback + const serverPromise = startLocalOAuthServer( + state, + OPENAI_OAUTH_CONFIG.defaultPort, + ); + + // 5. Try to open browser try { const { default: open } = await import("open"); const subprocess = await open(authorizationUrl, { wait: false }); - browserOpened = true; // Handle errors from the spawned process (e.g., xdg-open not found in containers) subprocess.on("error", () => { // Silently ignore - user can still manually visit the URL @@ -208,143 +241,81 @@ export async function handleConnect( // If auto-open fails, user can still manually visit the URL } - // 5. Show instructions - const browserMsg = browserOpened - ? "Opening browser for authorization..." - : "Please open the following URL in your browser:"; - + // 6. Wait for callback updateCommandResult( ctx.buffersRef, ctx.refreshDerived, cmdId, msg, - `${browserMsg}\n\n${authorizationUrl}\n\n` + - "After authorizing, you'll be redirected to a page showing: code#state\n" + - "Copy the entire value and run:\n\n" + - " /connect claude \n\n" + - "Example: /connect claude abc123...#def456...", + `Waiting for authorization...\n\n` + + `Please complete the sign-in process in your browser.\n` + + `The page will redirect automatically when done.\n\n` + + `If needed, visit:\n${authorizationUrl}`, true, - "finished", + "running", ); - } catch (error) { - // Clear any partial state - settingsManager.clearOAuthState(); + const { result, server } = await serverPromise; + + // Close the server + server.close(); + + // 7. Exchange code for tokens updateCommandResult( ctx.buffersRef, ctx.refreshDerived, cmdId, msg, - `✗ Failed to start OAuth flow: ${getErrorMessage(error)}`, - false, - "finished", + "Authorization received! Exchanging code for tokens...", + true, + "running", ); - } finally { - ctx.setCommandRunning(false); - } -} -/** - * Complete OAuth flow after user provides authorization code - * Accepts either: - * - Just the code: "n3nzU6B7gMep..." - * - Code#state format: "n3nzU6B7gMep...#9ba626d8..." - */ -async function completeOAuthFlow( - ctx: ConnectCommandContext, - msg: string, - authCodeInput: string, -): Promise { - // Show initial status - const cmdId = addCommandResult( - ctx.buffersRef, - ctx.refreshDerived, - msg, - "Exchanging authorization code for tokens...", - true, - "running", - ); - - ctx.setCommandRunning(true); - - try { - // 1. Get stored OAuth state - const storedState = settingsManager.getOAuthState(); - if (!storedState) { - throw new Error( - "No pending OAuth flow found. Please run '/connect claude' first to start the authorization.", - ); - } - - // 2. Check if state is too old (5 minute timeout) - const fiveMinutes = 5 * 60 * 1000; - if (Date.now() - storedState.timestamp > fiveMinutes) { - settingsManager.clearOAuthState(); - throw new Error( - "OAuth session expired. Please run '/connect claude' again to start a new authorization.", - ); - } - - // 3. Parse code#state format if provided - let authCode = authCodeInput; - let stateFromInput: string | undefined; - if (authCodeInput.includes("#")) { - const [code, stateVal] = authCodeInput.split("#"); - authCode = code ?? authCodeInput; - stateFromInput = stateVal; - // Validate state matches what we stored - if (stateVal && stateVal !== storedState.state) { - throw new Error( - "State mismatch - the authorization may have been tampered with. Please try again.", - ); - } - } - - // Use state from input if provided, otherwise use stored state - const stateToUse = stateFromInput || storedState.state; - - // 4. Exchange code for tokens const tokens = await exchangeCodeForTokens( - authCode, - storedState.codeVerifier, - stateToUse, + result.code, + codeVerifier, + redirectUri, ); - // 5. Update status + // 8. Extract account ID from JWT updateCommandResult( ctx.buffersRef, ctx.refreshDerived, cmdId, msg, - "Validating credentials...", + "Extracting account information...", true, "running", ); - // 6. Validate tokens work - const isValid = await validateAnthropicCredentials(tokens.access_token); - if (!isValid) { + let accountId: string; + try { + accountId = extractAccountIdFromToken(tokens.access_token); + } catch (error) { throw new Error( - "Token validation failed - the token may not have the required permissions.", + `Failed to extract account ID from token. This may indicate an incompatible account type. Error: ${error instanceof Error ? error.message : String(error)}`, ); } - // 7. Store tokens locally - settingsManager.storeAnthropicTokens(tokens); - - // 8. Update status for provider creation + // 9. Create or update provider in Letta with OAuth config + // Backend handles request transformation to ChatGPT backend API updateCommandResult( ctx.buffersRef, ctx.refreshDerived, cmdId, msg, - "Creating Anthropic provider...", + "Creating OpenAI Codex provider...", true, "running", ); - // 9. Create or update provider in Letta with the access token - await createOrUpdateAnthropicProvider(tokens.access_token); + await createOrUpdateOpenAICodexProvider({ + access_token: tokens.access_token, + id_token: tokens.id_token, + refresh_token: tokens.refresh_token, + account_id: accountId, + expires_at: Date.now() + tokens.expires_in * 1000, + }); // 10. Clear OAuth state settingsManager.clearOAuthState(); @@ -355,27 +326,36 @@ async function completeOAuthFlow( ctx.refreshDerived, cmdId, msg, - `✓ Successfully connected to Claude via OAuth!\n\n` + - `Provider '${ANTHROPIC_PROVIDER_NAME}' created/updated in Letta.\n` + - `Your OAuth tokens are stored securely in ~/.letta/settings.json`, + `\u2713 Successfully connected to OpenAI Codex!\n\n` + + `Provider '${OPENAI_CODEX_PROVIDER_NAME}' created/updated in Letta.\n` + + `Your ChatGPT Plus/Pro subscription is now linked.`, true, "finished", ); + + // 12. Show model selector to let user switch to a ChatGPT Plus/Pro model + if (ctx.onCodexConnected) { + // Small delay to let the success message render first + setTimeout(() => ctx.onCodexConnected?.(), 500); + } } catch (error) { + // Clear any partial state + settingsManager.clearOAuthState(); + // Check if this is a plan upgrade requirement error from provider creation const errorMessage = getErrorMessage(error); let displayMessage: string; if (errorMessage === "PLAN_UPGRADE_REQUIRED") { displayMessage = - `✗ Claude OAuth requires a Pro or Enterprise plan\n\n` + + `\u2717 OpenAI Codex OAuth requires a Pro or Enterprise plan\n\n` + `This feature is only available for Letta Pro or Enterprise customers.\n` + `To upgrade your plan, visit:\n\n` + ` https://app.letta.com/settings/organization/usage\n\n` + - `If you have an Anthropic API key, you can use it directly by setting:\n` + - ` export ANTHROPIC_API_KEY=your-key`; + `If you have an OpenAI API key, you can use it directly by setting:\n` + + ` export OPENAI_API_KEY=your-key`; } else { - displayMessage = `✗ Failed to connect: ${errorMessage}`; + displayMessage = `\u2717 Failed to connect: ${errorMessage}`; } updateCommandResult( @@ -409,7 +389,7 @@ export async function handleDisconnect( ctx.buffersRef, ctx.refreshDerived, msg, - "Usage: /disconnect \n\nAvailable providers: claude, zai", + "Usage: /disconnect \n\nAvailable providers: codex, claude, zai", false, ); return; @@ -421,7 +401,13 @@ export async function handleDisconnect( return; } - // Handle /disconnect claude + // Handle /disconnect codex + if (provider === "codex") { + await handleDisconnectCodex(ctx, msg); + return; + } + + // Handle /disconnect claude (legacy - for users who connected before) if (provider === "claude") { await handleDisconnectClaude(ctx, msg); return; @@ -432,25 +418,26 @@ export async function handleDisconnect( ctx.buffersRef, ctx.refreshDerived, msg, - `Error: Unknown provider "${provider}"\n\nAvailable providers: claude, zai\nUsage: /disconnect `, + `Error: Unknown provider "${provider}"\n\nAvailable providers: codex, claude, zai\nUsage: /disconnect `, false, ); } /** - * Handle /disconnect claude + * Handle /disconnect codex */ -async function handleDisconnectClaude( +async function handleDisconnectCodex( ctx: ConnectCommandContext, msg: string, ): Promise { - // Check if connected - if (!settingsManager.hasAnthropicOAuth()) { + // Check if provider exists on backend + const existingProvider = await getOpenAICodexProvider(); + if (!existingProvider) { addCommandResult( ctx.buffersRef, ctx.refreshDerived, msg, - "Not currently connected to Claude via OAuth.\n\nUse /connect claude to authenticate.", + "Not currently connected to OpenAI Codex via OAuth.\n\nUse /connect codex to authenticate.", false, ); return; @@ -461,7 +448,7 @@ async function handleDisconnectClaude( ctx.buffersRef, ctx.refreshDerived, msg, - "Disconnecting from Claude OAuth...", + "Disconnecting from OpenAI Codex OAuth...", true, "running", ); @@ -469,37 +456,117 @@ async function handleDisconnectClaude( ctx.setCommandRunning(true); try { - // Remove provider from Letta - await removeAnthropicProvider(); - - // Clear local tokens - settingsManager.clearAnthropicOAuth(); + // Remove provider from Letta backend + await removeOpenAICodexProvider(); updateCommandResult( ctx.buffersRef, ctx.refreshDerived, cmdId, msg, - `✓ Disconnected from Claude OAuth.\n\n` + - `Provider '${ANTHROPIC_PROVIDER_NAME}' removed from Letta.`, + `\u2713 Disconnected from OpenAI Codex OAuth.\n\n` + + `Provider '${OPENAI_CODEX_PROVIDER_NAME}' removed from Letta.`, true, "finished", ); } catch (error) { - // Still clear local tokens even if provider removal fails - settingsManager.clearAnthropicOAuth(); + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `\u2717 Failed to disconnect from OpenAI Codex: ${getErrorMessage(error)}`, + false, + "finished", + ); + } finally { + ctx.setCommandRunning(false); + } +} + +/** + * Handle /disconnect claude (legacy provider removal) + * This allows users who connected Claude before it was replaced with Codex + * to remove the old claude-pro-max provider + */ +async function handleDisconnectClaude( + ctx: ConnectCommandContext, + msg: string, +): Promise { + const CLAUDE_PROVIDER_NAME = "claude-pro-max"; + + // Show running status + const cmdId = addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Checking for Claude provider...", + true, + "running", + ); + + ctx.setCommandRunning(true); + + try { + // Check if claude-pro-max provider exists + const { listProviders } = await import( + "../../providers/openai-codex-provider" + ); + const providers = await listProviders(); + const claudeProvider = providers.find( + (p) => p.name === CLAUDE_PROVIDER_NAME, + ); + + if (!claudeProvider) { + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `No Claude provider found.\n\nThe '${CLAUDE_PROVIDER_NAME}' provider does not exist in your Letta account.`, + false, + "finished", + ); + return; + } + + // Remove provider from Letta + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + "Removing Claude provider...", + true, + "running", + ); + + const { deleteOpenAICodexProvider } = await import( + "../../providers/openai-codex-provider" + ); + await deleteOpenAICodexProvider(claudeProvider.id); updateCommandResult( ctx.buffersRef, ctx.refreshDerived, cmdId, msg, - `✓ Disconnected from Claude OAuth.\n\n` + - `Warning: Failed to remove provider from Letta: ${getErrorMessage(error)}\n` + - `Your local OAuth tokens have been removed.`, + `✓ Disconnected from Claude.\n\n` + + `Provider '${CLAUDE_PROVIDER_NAME}' has been removed from Letta.\n\n` + + `Note: /connect claude has been replaced with /connect codex for OpenAI ChatGPT Plus/Pro.`, true, "finished", ); + } catch (error) { + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `✗ Failed to disconnect from Claude: ${getErrorMessage(error)}`, + false, + "finished", + ); } finally { ctx.setCommandRunning(false); } @@ -546,7 +613,7 @@ async function handleDisconnectZai( ctx.refreshDerived, cmdId, msg, - `✓ Disconnected from Zai.\n\n` + + `\u2713 Disconnected from Zai.\n\n` + `Provider '${ZAI_PROVIDER_NAME}' removed from Letta.`, true, "finished", @@ -557,7 +624,7 @@ async function handleDisconnectZai( ctx.refreshDerived, cmdId, msg, - `✗ Failed to disconnect from Zai: ${getErrorMessage(error)}`, + `\u2717 Failed to disconnect from Zai: ${getErrorMessage(error)}`, false, "finished", ); @@ -615,9 +682,9 @@ export async function handleConnectZai( ctx.refreshDerived, cmdId, msg, - `✓ Successfully connected to Zai!\n\n` + + `\u2713 Successfully connected to Zai!\n\n` + `Provider '${ZAI_PROVIDER_NAME}' created in Letta.\n\n` + - `The models are populated in /model → "All Available Models"`, + `The models are populated in /model \u2192 "All Available Models"`, true, "finished", ); @@ -627,7 +694,7 @@ export async function handleConnectZai( ctx.refreshDerived, cmdId, msg, - `✗ Failed to create Zai provider: ${getErrorMessage(error)}`, + `\u2717 Failed to create Zai provider: ${getErrorMessage(error)}`, false, "finished", ); diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 538d059..9b47baf 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -265,7 +265,7 @@ export const commands: Record = { // === Session management (order 40-49) === "/connect": { - desc: "Connect an existing account (/connect zai )", + desc: "Connect an existing account (/connect codex or /connect zai )", order: 40, handler: () => { // Handled specially in App.tsx @@ -273,7 +273,7 @@ export const commands: Record = { }, }, "/disconnect": { - desc: "Disconnect an existing account (/disconnect zai)", + desc: "Disconnect an existing account (/disconnect codex|claude|zai)", order: 41, handler: () => { // Handled specially in App.tsx diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index d5466cf..21dd46e 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -20,7 +20,7 @@ import { } from "../../constants"; import type { PermissionMode } from "../../permissions/mode"; import { permissionMode } from "../../permissions/mode"; -import { ANTHROPIC_PROVIDER_NAME } from "../../providers/anthropic-provider"; +import { OPENAI_CODEX_PROVIDER_NAME } from "../../providers/openai-codex-provider"; import { ralphMode } from "../../ralph/mode"; import { settingsManager } from "../../settings-manager"; import { charsToTokens, formatCompact } from "../helpers/format"; @@ -50,7 +50,7 @@ const InputFooter = memo(function InputFooter({ showExitHint, agentName, currentModel, - isAnthropicProvider, + isOpenAICodexProvider, }: { ctrlCPressed: boolean; escapePressed: boolean; @@ -60,7 +60,7 @@ const InputFooter = memo(function InputFooter({ showExitHint: boolean; agentName: string | null | undefined; currentModel: string | null | undefined; - isAnthropicProvider: boolean; + isOpenAICodexProvider: boolean; }) { return ( @@ -90,8 +90,8 @@ const InputFooter = memo(function InputFooter({ {agentName || "Unnamed"} {` [${currentModel ?? "unknown"}]`} @@ -838,7 +838,9 @@ export function Input({ showExitHint={ralphActive || ralphPending} agentName={agentName} currentModel={currentModel} - isAnthropicProvider={currentModelProvider === ANTHROPIC_PROVIDER_NAME} + isOpenAICodexProvider={ + currentModelProvider === OPENAI_CODEX_PROVIDER_NAME + } /> diff --git a/src/cli/components/ModelSelector.tsx b/src/cli/components/ModelSelector.tsx index 7af7475..df4e565 100644 --- a/src/cli/components/ModelSelector.tsx +++ b/src/cli/components/ModelSelector.tsx @@ -28,12 +28,18 @@ interface ModelSelectorProps { currentModelId?: string; onSelect: (modelId: string) => void; onCancel: () => void; + /** Filter models to only show those matching this provider prefix (e.g., "chatgpt-plus-pro") */ + filterProvider?: string; + /** Force refresh the models list on mount */ + forceRefresh?: boolean; } export function ModelSelector({ currentModelId, onSelect, onCancel, + filterProvider, + forceRefresh: forceRefreshOnMount, }: ModelSelectorProps) { const typedModels = models as UiModel[]; const [category, setCategory] = useState("supported"); @@ -94,8 +100,8 @@ export function ModelSelector({ }); useEffect(() => { - loadModels.current(false); - }, []); + loadModels.current(forceRefreshOnMount ?? false); + }, [forceRefreshOnMount]); // Handles from models.json (for filtering "all" category) const staticModelHandles = useMemo( @@ -105,16 +111,23 @@ export function ModelSelector({ // Supported models: models.json entries that are available // Featured models first, then non-featured, preserving JSON order within each group + // If filterProvider is set, only show models from that provider const supportedModels = useMemo(() => { if (availableHandles === undefined) return []; - const available = + let available = availableHandles === null ? typedModels // fallback : typedModels.filter((m) => availableHandles.has(m.handle)); + // Apply provider filter if specified + if (filterProvider) { + available = available.filter((m) => + m.handle.startsWith(`${filterProvider}/`), + ); + } const featured = available.filter((m) => m.isFeatured); const nonFeatured = available.filter((m) => !m.isFeatured); return [...featured, ...nonFeatured]; - }, [typedModels, availableHandles]); + }, [typedModels, availableHandles, filterProvider]); // All other models: API handles not in models.json const otherModelHandles = useMemo(() => { diff --git a/src/cli/components/OAuthCodeDialog.tsx b/src/cli/components/OAuthCodeDialog.tsx deleted file mode 100644 index ca2bba4..0000000 --- a/src/cli/components/OAuthCodeDialog.tsx +++ /dev/null @@ -1,419 +0,0 @@ -import { Box, Text, useInput } from "ink"; -import { memo, useEffect, useState } from "react"; -import { - exchangeCodeForTokens, - startAnthropicOAuth, - validateAnthropicCredentials, -} from "../../auth/anthropic-oauth"; -import { - ANTHROPIC_PROVIDER_NAME, - checkAnthropicOAuthEligibility, - createOrUpdateAnthropicProvider, -} from "../../providers/anthropic-provider"; -import { settingsManager } from "../../settings-manager"; -import { colors } from "./colors"; -import { PasteAwareTextInput } from "./PasteAwareTextInput"; - -type Props = { - onComplete: (success: boolean, message: string) => void; - onCancel: () => void; - onModelSwitch?: (modelHandle: string) => Promise; -}; - -type FlowState = - | "initializing" - | "checking_eligibility" - | "waiting_for_code" - | "exchanging" - | "validating" - | "creating_provider" - | "fetching_models" - | "select_model" - | "switching_model" - | "success" - | "error"; - -export const OAuthCodeDialog = memo( - ({ onComplete, onCancel, onModelSwitch }: Props) => { - const [flowState, setFlowState] = useState("initializing"); - const [authUrl, setAuthUrl] = useState(""); - const [codeInput, setCodeInput] = useState(""); - const [errorMessage, setErrorMessage] = useState(""); - const [codeVerifier, setCodeVerifier] = useState(""); - const [state, setState] = useState(""); - const [availableModels, setAvailableModels] = useState([]); - const [selectedModelIndex, setSelectedModelIndex] = useState(0); - - // Initialize OAuth flow on mount - useEffect(() => { - const initFlow = async () => { - try { - // Check if already connected - if ( - settingsManager.hasAnthropicOAuth() && - !settingsManager.isAnthropicTokenExpired() - ) { - onComplete( - false, - "Already connected to Claude via OAuth.\n\nUse /disconnect to remove the current connection first.", - ); - return; - } - - // Check eligibility before starting OAuth flow - setFlowState("checking_eligibility"); - const eligibility = await checkAnthropicOAuthEligibility(); - if (!eligibility.eligible) { - onComplete( - false, - `✗ Claude OAuth requires a Pro or Enterprise plan\n\n` + - `This feature is only available for Letta Pro or Enterprise customers.\n` + - `Current plan: ${eligibility.billing_tier}\n\n` + - `To upgrade your plan, visit:\n\n` + - ` https://app.letta.com/settings/organization/usage\n\n` + - `If you have an Anthropic API key, you can use it directly by setting:\n` + - ` export ANTHROPIC_API_KEY=your-key`, - ); - return; - } - - // Start OAuth flow - const { - authorizationUrl, - state: oauthState, - codeVerifier: verifier, - } = await startAnthropicOAuth(); - - // Store state for validation - settingsManager.storeOAuthState(oauthState, verifier, "anthropic"); - - setAuthUrl(authorizationUrl); - setCodeVerifier(verifier); - setState(oauthState); - setFlowState("waiting_for_code"); - - // Try to open browser - try { - const { default: open } = await import("open"); - const subprocess = await open(authorizationUrl, { wait: false }); - subprocess.on("error", () => { - // Silently ignore - user can manually visit URL - }); - } catch { - // If auto-open fails, user can still manually visit the URL - } - } catch (error) { - setErrorMessage( - error instanceof Error ? error.message : String(error), - ); - setFlowState("error"); - } - }; - - initFlow(); - }, [onComplete]); - - // Handle keyboard input - useInput((input, key) => { - // CTRL-C: cancel at any cancelable state - if (key.ctrl && input === "c") { - if (flowState === "waiting_for_code" || flowState === "select_model") { - settingsManager.clearOAuthState(); - onCancel(); - } - return; - } - - if (key.escape && flowState === "waiting_for_code") { - settingsManager.clearOAuthState(); - onCancel(); - } - - // Handle model selection navigation - if (flowState === "select_model") { - if (key.upArrow) { - setSelectedModelIndex((prev) => Math.max(0, prev - 1)); - } else if (key.downArrow) { - setSelectedModelIndex((prev) => - Math.min(availableModels.length - 1, prev + 1), - ); - } else if (key.return && onModelSwitch) { - // Select current model - const selectedModel = availableModels[selectedModelIndex]; - if (selectedModel) { - handleModelSelection(selectedModel); - } - } else if (key.escape) { - // Skip model selection - skipModelSelection(); - } - } - }); - - // Handle model selection - const handleModelSelection = async (modelHandle: string) => { - if (!onModelSwitch) return; - - setFlowState("switching_model"); - try { - await onModelSwitch(modelHandle); - setFlowState("success"); - onComplete( - true, - `✓ Successfully connected to Claude via OAuth!\n\n` + - `Provider '${ANTHROPIC_PROVIDER_NAME}' created/updated in Letta.\n` + - `Switched to model: ${modelHandle.replace(`${ANTHROPIC_PROVIDER_NAME}/`, "")}`, - ); - } catch (error) { - setErrorMessage(error instanceof Error ? error.message : String(error)); - setFlowState("error"); - } - }; - - // Skip model selection - const skipModelSelection = () => { - setFlowState("success"); - onComplete( - true, - `✓ Successfully connected to Claude via OAuth!\n\n` + - `Provider '${ANTHROPIC_PROVIDER_NAME}' created/updated in Letta.\n` + - `Your OAuth tokens are stored securely in ~/.letta/settings.json\n` + - `Use /model to switch to a Claude model.`, - ); - }; - - // Handle code submission - const handleSubmit = async (input: string) => { - if (!input.trim()) return; - - try { - setFlowState("exchanging"); - - // Parse code#state format - let authCode = input.trim(); - let stateFromInput: string | undefined; - - if (authCode.includes("#")) { - const [code, inputState] = authCode.split("#"); - authCode = code ?? input.trim(); - stateFromInput = inputState; - - // Validate state matches - if (stateFromInput && stateFromInput !== state) { - throw new Error( - "State mismatch - the authorization may have been tampered with. Please try again.", - ); - } - } - - const stateToUse = stateFromInput || state; - - // Exchange code for tokens - const tokens = await exchangeCodeForTokens( - authCode, - codeVerifier, - stateToUse, - ); - - setFlowState("validating"); - - // Validate tokens - const isValid = await validateAnthropicCredentials(tokens.access_token); - if (!isValid) { - throw new Error( - "Token validation failed - the token may not have the required permissions.", - ); - } - - // Store tokens locally - settingsManager.storeAnthropicTokens(tokens); - - setFlowState("creating_provider"); - - // Create/update provider in Letta - await createOrUpdateAnthropicProvider(tokens.access_token); - - // Clear OAuth state - settingsManager.clearOAuthState(); - - // If we have a model switch handler, try to fetch available models - if (onModelSwitch) { - setFlowState("fetching_models"); - try { - const { getAvailableModelHandles } = await import( - "../../agent/available-models" - ); - const result = await getAvailableModelHandles({ - forceRefresh: true, - }); - - // Filter to only claude-pro-max models - const claudeModels = Array.from(result.handles) - .filter((h) => h.startsWith(`${ANTHROPIC_PROVIDER_NAME}/`)) - .sort(); - - if (claudeModels.length > 0) { - setAvailableModels(claudeModels); - setFlowState("select_model"); - return; // Don't complete yet, wait for model selection - } - } catch { - // If fetching models fails, just complete without selection - } - } - - setFlowState("success"); - onComplete( - true, - `✓ Successfully connected to Claude via OAuth!\n\n` + - `Provider '${ANTHROPIC_PROVIDER_NAME}' created/updated in Letta.\n` + - `Your OAuth tokens are stored securely in ~/.letta/settings.json\n` + - `Use /model to switch to a Claude model.`, - ); - } catch (error) { - setErrorMessage(error instanceof Error ? error.message : String(error)); - setFlowState("error"); - } - }; - - if (flowState === "initializing" || flowState === "checking_eligibility") { - return ( - - - {flowState === "checking_eligibility" - ? "Checking account eligibility..." - : "Starting Claude OAuth flow..."} - - - ); - } - - if (flowState === "error") { - return ( - - ✗ OAuth Error: {errorMessage} - - Press any key to close - - { - settingsManager.clearOAuthState(); - onComplete(false, `✗ Failed to connect: ${errorMessage}`); - }} - /> - - ); - } - - // Model selection UI - if (flowState === "select_model") { - return ( - - - - [Claude OAuth] - - Connected! - - - - Select a model to switch to: - - - - {availableModels.map((model, index) => { - const displayName = model.replace( - `${ANTHROPIC_PROVIDER_NAME}/`, - "", - ); - const isSelected = index === selectedModelIndex; - return ( - - - {isSelected ? "› " : " "} - {displayName} - - - ); - })} - - - - ↑↓ to select, Enter to confirm, Esc to skip - - - ); - } - - if (flowState !== "waiting_for_code") { - const statusMessages: Record = { - exchanging: "Exchanging authorization code for tokens...", - validating: "Validating credentials...", - creating_provider: "Creating Claude provider...", - fetching_models: "Fetching available models...", - switching_model: "Switching model...", - success: "Success!", - }; - - return ( - - - {statusMessages[flowState]} - - - ); - } - - return ( - - - - [Claude OAuth] - - - - - Opening browser for authorization... - - - - If browser doesn't open, copy this URL: - {authUrl} - - - - - After authorizing, copy the code value from the - page and paste it below: - - - - - > - - - - - Enter to submit, Esc to cancel - - - ); - }, -); - -OAuthCodeDialog.displayName = "OAuthCodeDialog"; - -// Helper component to wait for any key press then close -const WaitForKeyThenClose = memo(({ onClose }: { onClose: () => void }) => { - useInput(() => { - onClose(); - }); - return null; -}); - -WaitForKeyThenClose.displayName = "WaitForKeyThenClose"; diff --git a/src/models.json b/src/models.json index 7cb9fd8..3f4cb62 100644 --- a/src/models.json +++ b/src/models.json @@ -47,46 +47,111 @@ } }, { - "id": "sonnet-4.5-pro-max", - "handle": "claude-pro-max/claude-sonnet-4-5-20250929", - "label": "Sonnet 4.5", - "description": "Sonnet 4.5 via Claude Pro/Max Plan", + "id": "gpt-5.2-codex-plus-pro-medium", + "handle": "chatgpt-plus-pro/gpt-5.2-codex", + "label": "GPT-5.2 Codex", + "description": "GPT-5.2 Codex (med reasoning) via ChatGPT Plus/Pro", "updateArgs": { - "context_window": 180000, - "max_output_tokens": 64000, - "max_reasoning_tokens": 31999 + "reasoning_effort": "medium", + "verbosity": "medium", + "context_window": 272000, + "max_output_tokens": 128000 } }, { - "id": "sonnet-4.5-no-reasoning-pro-max", - "handle": "claude-pro-max/claude-sonnet-4-5-20250929", - "label": "Sonnet 4.5", - "description": "Sonnet 4.5 (no reasoning) via Claude Pro/Max Plan", + "id": "gpt-5.2-codex-plus-pro-high", + "handle": "chatgpt-plus-pro/gpt-5.2-codex", + "label": "GPT-5.2 Codex", + "description": "GPT-5.2 Codex (high reasoning) via ChatGPT Plus/Pro", "updateArgs": { - "enable_reasoner": false, - "context_window": 180000, - "max_output_tokens": 64000 + "reasoning_effort": "high", + "verbosity": "medium", + "context_window": 272000, + "max_output_tokens": 128000 } }, { - "id": "opus-pro-max", - "handle": "claude-pro-max/claude-opus-4-5-20251101", - "label": "Opus 4.5", - "description": "Opus 4.5 via Claude Pro/Max Plan", + "id": "gpt-5.2-plus-pro-medium", + "handle": "chatgpt-plus-pro/gpt-5.2", + "label": "GPT-5.2", + "description": "GPT-5.2 (med reasoning) via ChatGPT Plus/Pro", "updateArgs": { - "context_window": 180000, - "max_output_tokens": 64000, - "max_reasoning_tokens": 31999 + "reasoning_effort": "medium", + "verbosity": "medium", + "context_window": 272000, + "max_output_tokens": 128000 } }, { - "id": "haiku-pro-max", - "handle": "claude-pro-max/claude-haiku-4-5-20251001", - "label": "Haiku 4.5", - "description": "Haiku 4.5 via Claude Pro/Max Plan", + "id": "gpt-5.2-plus-pro-high", + "handle": "chatgpt-plus-pro/gpt-5.2", + "label": "GPT-5.2", + "description": "GPT-5.2 (high reasoning) via ChatGPT Plus/Pro", "updateArgs": { - "context_window": 180000, - "max_output_tokens": 64000 + "reasoning_effort": "high", + "verbosity": "medium", + "context_window": 272000, + "max_output_tokens": 128000 + } + }, + { + "id": "gpt-5.1-codex-plus-pro-medium", + "handle": "chatgpt-plus-pro/gpt-5.1-codex", + "label": "GPT-5.1 Codex", + "description": "GPT-5.1 Codex (med reasoning) via ChatGPT Plus/Pro", + "updateArgs": { + "reasoning_effort": "medium", + "verbosity": "medium", + "context_window": 272000, + "max_output_tokens": 128000 + } + }, + { + "id": "gpt-5.1-codex-plus-pro-high", + "handle": "chatgpt-plus-pro/gpt-5.1-codex", + "label": "GPT-5.1 Codex", + "description": "GPT-5.1 Codex (high reasoning) via ChatGPT Plus/Pro", + "updateArgs": { + "reasoning_effort": "high", + "verbosity": "medium", + "context_window": 272000, + "max_output_tokens": 128000 + } + }, + { + "id": "gpt-5.1-codex-max-plus-pro-medium", + "handle": "chatgpt-plus-pro/gpt-5.1-codex-max", + "label": "GPT-5.1 Codex Max", + "description": "GPT-5.1 Codex Max (med reasoning) via ChatGPT Plus/Pro", + "updateArgs": { + "reasoning_effort": "medium", + "verbosity": "medium", + "context_window": 272000, + "max_output_tokens": 128000 + } + }, + { + "id": "gpt-5.1-codex-max-plus-pro-high", + "handle": "chatgpt-plus-pro/gpt-5.1-codex-max", + "label": "GPT-5.1 Codex Max", + "description": "GPT-5.1 Codex Max (high reasoning) via ChatGPT Plus/Pro", + "updateArgs": { + "reasoning_effort": "high", + "verbosity": "medium", + "context_window": 272000, + "max_output_tokens": 128000 + } + }, + { + "id": "gpt-5.1-codex-max-plus-pro-xhigh", + "handle": "chatgpt-plus-pro/gpt-5.1-codex-max", + "label": "GPT-5.1 Codex Max", + "description": "GPT-5.1 Codex Max (extra-high reasoning) via ChatGPT Plus/Pro", + "updateArgs": { + "reasoning_effort": "xhigh", + "verbosity": "medium", + "context_window": 272000, + "max_output_tokens": 128000 } }, { diff --git a/src/providers/anthropic-provider.ts b/src/providers/openai-codex-provider.ts similarity index 54% rename from src/providers/anthropic-provider.ts rename to src/providers/openai-codex-provider.ts index 64eb989..7a839f4 100644 --- a/src/providers/anthropic-provider.ts +++ b/src/providers/openai-codex-provider.ts @@ -1,13 +1,29 @@ /** - * Direct API calls to Letta for managing Anthropic provider - * Bypasses SDK since it doesn't expose providers API + * Direct API calls to Letta for managing OpenAI Codex provider + * Uses the chatgpt_oauth provider type - backend handles request transformation + * (transforms OpenAI API format → ChatGPT backend API format) */ import { LETTA_CLOUD_API_URL } from "../auth/oauth"; import { settingsManager } from "../settings-manager"; -// Provider name constant for letta-code's Anthropic OAuth provider -export const ANTHROPIC_PROVIDER_NAME = "claude-pro-max"; +// Provider name constant for letta-code's OpenAI Codex OAuth provider +export const OPENAI_CODEX_PROVIDER_NAME = "chatgpt-plus-pro"; + +// Provider type for ChatGPT OAuth (backend handles transformation) +export const CHATGPT_OAUTH_PROVIDER_TYPE = "chatgpt_oauth"; + +/** + * ChatGPT OAuth configuration sent to Letta backend + * Backend uses this to authenticate with ChatGPT and transform requests + */ +export interface ChatGPTOAuthConfig { + access_token: string; + id_token: string; + refresh_token?: string; + account_id: string; + expires_at: number; // Unix timestamp in milliseconds +} interface ProviderResponse { id: string; @@ -116,119 +132,112 @@ export async function listProviders(): Promise { } /** - * Get the letta-code-claude provider if it exists + * Get the chatgpt-plus-pro provider if it exists */ -export async function getAnthropicProvider(): Promise { +export async function getOpenAICodexProvider(): Promise { const providers = await listProviders(); - return providers.find((p) => p.name === ANTHROPIC_PROVIDER_NAME) || null; + return providers.find((p) => p.name === OPENAI_CODEX_PROVIDER_NAME) || null; } /** - * Create a new Anthropic provider with OAuth access token + * Create a new ChatGPT OAuth provider + * OAuth config is JSON-encoded in api_key field to avoid backend schema changes + * Backend parses api_key as JSON when provider_type is "chatgpt_oauth" */ -export async function createAnthropicProvider( - accessToken: string, +export async function createOpenAICodexProvider( + config: ChatGPTOAuthConfig, ): Promise { + // Encode OAuth config as JSON in api_key field + const apiKeyJson = JSON.stringify({ + access_token: config.access_token, + id_token: config.id_token, + refresh_token: config.refresh_token, + account_id: config.account_id, + expires_at: config.expires_at, + }); + return providersRequest("POST", "/v1/providers", { - name: ANTHROPIC_PROVIDER_NAME, - provider_type: "anthropic", - api_key: accessToken, + name: OPENAI_CODEX_PROVIDER_NAME, + provider_type: CHATGPT_OAUTH_PROVIDER_TYPE, + api_key: apiKeyJson, }); } /** - * Update an existing Anthropic provider with new access token + * Update an existing ChatGPT OAuth provider with new OAuth config + * OAuth config is JSON-encoded in api_key field */ -export async function updateAnthropicProvider( +export async function updateOpenAICodexProvider( providerId: string, - accessToken: string, + config: ChatGPTOAuthConfig, ): Promise { + // Encode OAuth config as JSON in api_key field + const apiKeyJson = JSON.stringify({ + access_token: config.access_token, + id_token: config.id_token, + refresh_token: config.refresh_token, + account_id: config.account_id, + expires_at: config.expires_at, + }); + return providersRequest( "PATCH", `/v1/providers/${providerId}`, { - api_key: accessToken, + api_key: apiKeyJson, }, ); } /** - * Delete the Anthropic provider + * Delete the OpenAI Codex provider */ -export async function deleteAnthropicProvider( +export async function deleteOpenAICodexProvider( providerId: string, ): Promise { await providersRequest("DELETE", `/v1/providers/${providerId}`); } /** - * Create or update the Anthropic provider with OAuth access token - * This is the main function called after successful /connect + * Create or update the ChatGPT OAuth provider + * This is the main function called after successful /connect codex + * + * The Letta backend will: + * 1. Store the OAuth tokens securely + * 2. Handle token refresh when needed + * 3. Transform requests from OpenAI format to ChatGPT backend format + * 4. Add required headers (Authorization, ChatGPT-Account-Id, etc.) + * 5. Forward to chatgpt.com/backend-api/codex */ -export async function createOrUpdateAnthropicProvider( - accessToken: string, +export async function createOrUpdateOpenAICodexProvider( + config: ChatGPTOAuthConfig, ): Promise { - const existing = await getAnthropicProvider(); + const existing = await getOpenAICodexProvider(); if (existing) { - // Update existing provider with new token - return updateAnthropicProvider(existing.id, accessToken); + // Update existing provider with new OAuth config + return updateOpenAICodexProvider(existing.id, config); } else { // Create new provider - return createAnthropicProvider(accessToken); + return createOpenAICodexProvider(config); } } /** - * Ensure the Anthropic provider has a valid (non-expired) token - * Call this before making requests that use the provider + * Remove the OpenAI Codex provider (called on /disconnect) */ -export async function ensureAnthropicProviderToken(): Promise { - const settings = settingsManager.getSettings(); - const tokens = settings.anthropicOAuth; - - if (!tokens) { - // No Anthropic OAuth configured, nothing to do - return; - } - - // Check if token is expired or about to expire (within 5 minutes) - const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000; - if (tokens.expires_at < fiveMinutesFromNow && tokens.refresh_token) { - // Token is expired or about to expire, refresh it - const { refreshAnthropicToken } = await import("../auth/anthropic-oauth"); - - try { - const newTokens = await refreshAnthropicToken(tokens.refresh_token); - settingsManager.storeAnthropicTokens(newTokens); - - // Update the provider with the new access token - const existing = await getAnthropicProvider(); - if (existing) { - await updateAnthropicProvider(existing.id, newTokens.access_token); - } - } catch (error) { - console.error("Failed to refresh Anthropic access token:", error); - // Continue with existing token, it might still work - } - } -} - -/** - * Remove the Anthropic provider (called on /disconnect) - */ -export async function removeAnthropicProvider(): Promise { - const existing = await getAnthropicProvider(); +export async function removeOpenAICodexProvider(): Promise { + const existing = await getOpenAICodexProvider(); if (existing) { - await deleteAnthropicProvider(existing.id); + await deleteOpenAICodexProvider(existing.id); } } /** - * Check if user is eligible for Anthropic OAuth + * Check if user is eligible for OpenAI Codex OAuth * Requires Pro or Enterprise billing tier */ -export async function checkAnthropicOAuthEligibility(): Promise { +export async function checkOpenAICodexEligibility(): Promise { try { const balance = await providersRequest( "GET", @@ -248,12 +257,12 @@ export async function checkAnthropicOAuthEligibility(): Promise { - console.error( - "Failed to persist settings after clearing Anthropic OAuth:", - error, - ); - }); - } - /** * Store OAuth state for pending authorization */ storeOAuthState( state: string, codeVerifier: string, - provider: "anthropic", + redirectUri: string, + provider: "openai", ): void { this.updateSettings({ oauthState: { state, codeVerifier, + redirectUri, provider, timestamp: Date.now(), },