feat: replace /connect claude with /connect codex for OpenAI OAuth (#527)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
jnjpng
2026-01-15 13:57:39 -08:00
committed by GitHub
parent 46d7f7ae45
commit bbb2c987e5
13 changed files with 858 additions and 1079 deletions

View File

@@ -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,

View File

@@ -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<string, unknown>,
): 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/");

View File

@@ -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<string> {
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<AnthropicTokens> {
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<AnthropicTokens> {
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<boolean> {
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<string | null> {
// 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<boolean> {
const token = await getAnthropicAccessToken();
if (!token) {
return false;
}
return validateAnthropicCredentials(token);
}

390
src/auth/openai-oauth.ts Normal file
View File

@@ -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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} - Letta Code</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #161616;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 64px;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.ascii-art {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace;
font-size: 12px;
line-height: 1.2;
color: #404040;
white-space: pre;
user-select: none;
margin-bottom: 48px;
}
.title {
font-size: 32px;
font-weight: 600;
color: #e5e5e5;
margin-bottom: 12px;
letter-spacing: -0.02em;
}
.message {
font-size: 16px;
color: #737373;
line-height: 1.5;
}
@media (max-width: 600px) {
.ascii-art { font-size: 8px; }
.title { font-size: 24px; }
.message { font-size: 14px; }
}
</style>
</head>
<body>
<div class="container">
<div class="ascii-art">${asciiLogo}</div>
<h1 class="title">${title}</h1>
<p class="message">${message}</p>
</div>
${autoClose ? `<script>setTimeout(() => window.close(), 2000);</script>` : ""}
</body>
</html>`;
}
/**
* 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<string> {
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<string, unknown> {
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<string, unknown>
| 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<OpenAITokens> {
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;
}

View File

@@ -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<ActiveOverlay>(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" && <HelpDialog onClose={closeOverlay} />}
{/* OAuth Code Dialog - for Claude OAuth connection */}
{activeOverlay === "oauth" && (
<OAuthCodeDialog
onComplete={(success, message) => {
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" && (
<NewAgentDialog

View File

@@ -3,15 +3,18 @@
import {
exchangeCodeForTokens,
startAnthropicOAuth,
validateAnthropicCredentials,
} from "../../auth/anthropic-oauth";
extractAccountIdFromToken,
OPENAI_OAUTH_CONFIG,
startLocalOAuthServer,
startOpenAIOAuth,
} from "../../auth/openai-oauth";
import {
ANTHROPIC_PROVIDER_NAME,
checkAnthropicOAuthEligibility,
createOrUpdateAnthropicProvider,
removeAnthropicProvider,
} from "../../providers/anthropic-provider";
checkOpenAICodexEligibility,
createOrUpdateOpenAICodexProvider,
getOpenAICodexProvider,
OPENAI_CODEX_PROVIDER_NAME,
removeOpenAICodexProvider,
} from "../../providers/openai-codex-provider";
import {
createOrUpdateZaiProvider,
getZaiProvider,
@@ -35,6 +38,7 @@ export interface ConnectCommandContext {
buffersRef: { current: Buffers };
refreshDerived: () => 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 <code>` 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<void> {
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 <provider> [options]\n\nAvailable providers:\n • claude - Connect via OAuth to authenticate without an API key\n • zai <api_key> - Connect to Zai with your API key",
"Usage: /connect <provider> [options]\n\nAvailable providers:\n \u2022 codex - Connect via OAuth to authenticate with ChatGPT Plus/Pro\n \u2022 zai <api_key> - 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 <provider> [options]`,
`Error: Unknown provider "${provider}"\n\nAvailable providers: codex, zai\nUsage: /connect <provider> [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<void> {
// 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 <code#state>\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<void> {
// 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 <provider>\n\nAvailable providers: claude, zai",
"Usage: /disconnect <provider>\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 <provider>`,
`Error: Unknown provider "${provider}"\n\nAvailable providers: codex, claude, zai\nUsage: /disconnect <provider>`,
false,
);
}
/**
* Handle /disconnect claude
* Handle /disconnect codex
*/
async function handleDisconnectClaude(
async function handleDisconnectCodex(
ctx: ConnectCommandContext,
msg: string,
): Promise<void> {
// 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<void> {
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",
);

View File

@@ -265,7 +265,7 @@ export const commands: Record<string, Command> = {
// === Session management (order 40-49) ===
"/connect": {
desc: "Connect an existing account (/connect zai <api-key>)",
desc: "Connect an existing account (/connect codex or /connect zai <api-key>)",
order: 40,
handler: () => {
// Handled specially in App.tsx
@@ -273,7 +273,7 @@ export const commands: Record<string, Command> = {
},
},
"/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

View File

@@ -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 (
<Box justifyContent="space-between" marginBottom={1}>
@@ -90,8 +90,8 @@ const InputFooter = memo(function InputFooter({
<Text>
<Text color={colors.footer.agentName}>{agentName || "Unnamed"}</Text>
<Text
dimColor={!isAnthropicProvider}
color={isAnthropicProvider ? "#FFC787" : undefined}
dimColor={!isOpenAICodexProvider}
color={isOpenAICodexProvider ? "#74AA9C" : undefined}
>
{` [${currentModel ?? "unknown"}]`}
</Text>
@@ -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
}
/>
</Box>
</Box>

View File

@@ -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<ModelCategory>("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(() => {

View File

@@ -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<void>;
};
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<FlowState>("initializing");
const [authUrl, setAuthUrl] = useState<string>("");
const [codeInput, setCodeInput] = useState("");
const [errorMessage, setErrorMessage] = useState<string>("");
const [codeVerifier, setCodeVerifier] = useState<string>("");
const [state, setState] = useState<string>("");
const [availableModels, setAvailableModels] = useState<string[]>([]);
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 (
<Box flexDirection="column" padding={1}>
<Text color={colors.status.processing}>
{flowState === "checking_eligibility"
? "Checking account eligibility..."
: "Starting Claude OAuth flow..."}
</Text>
</Box>
);
}
if (flowState === "error") {
return (
<Box flexDirection="column" padding={1}>
<Text color="red"> OAuth Error: {errorMessage}</Text>
<Box marginTop={1}>
<Text dimColor>Press any key to close</Text>
</Box>
<WaitForKeyThenClose
onClose={() => {
settingsManager.clearOAuthState();
onComplete(false, `✗ Failed to connect: ${errorMessage}`);
}}
/>
</Box>
);
}
// Model selection UI
if (flowState === "select_model") {
return (
<Box flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text color={colors.approval.header} bold>
[Claude OAuth]
</Text>
<Text color="green"> Connected!</Text>
</Box>
<Box marginBottom={1}>
<Text>Select a model to switch to:</Text>
</Box>
<Box flexDirection="column" marginBottom={1}>
{availableModels.map((model, index) => {
const displayName = model.replace(
`${ANTHROPIC_PROVIDER_NAME}/`,
"",
);
const isSelected = index === selectedModelIndex;
return (
<Box key={model}>
<Text color={isSelected ? colors.approval.header : undefined}>
{isSelected ? " " : " "}
{displayName}
</Text>
</Box>
);
})}
</Box>
<Box marginTop={1}>
<Text dimColor> to select, Enter to confirm, Esc to skip</Text>
</Box>
</Box>
);
}
if (flowState !== "waiting_for_code") {
const statusMessages: Record<string, string> = {
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 (
<Box flexDirection="column" padding={1}>
<Text color={colors.status.processing}>
{statusMessages[flowState]}
</Text>
</Box>
);
}
return (
<Box flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text color={colors.approval.header} bold>
[Claude OAuth]
</Text>
</Box>
<Box marginBottom={1}>
<Text>Opening browser for authorization...</Text>
</Box>
<Box marginBottom={1} flexDirection="column">
<Text dimColor>If browser doesn't open, copy this URL:</Text>
<Text color={colors.link.url}>{authUrl}</Text>
</Box>
<Box marginBottom={1} flexDirection="column">
<Text>
After authorizing, copy the <Text bold>code</Text> value from the
page and paste it below:
</Text>
</Box>
<Box>
<Text color={colors.approval.header}>&gt; </Text>
<PasteAwareTextInput
value={codeInput}
onChange={setCodeInput}
onSubmit={handleSubmit}
placeholder="Paste code here..."
/>
</Box>
<Box marginTop={1}>
<Text dimColor>Enter to submit, Esc to cancel</Text>
</Box>
</Box>
);
},
);
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";

View File

@@ -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
}
},
{

View File

@@ -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<ProviderResponse[]> {
}
/**
* Get the letta-code-claude provider if it exists
* Get the chatgpt-plus-pro provider if it exists
*/
export async function getAnthropicProvider(): Promise<ProviderResponse | null> {
export async function getOpenAICodexProvider(): Promise<ProviderResponse | null> {
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<ProviderResponse> {
// 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<ProviderResponse>("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<ProviderResponse> {
// 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<ProviderResponse>(
"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<void> {
await providersRequest<void>("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<ProviderResponse> {
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<void> {
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<void> {
const existing = await getAnthropicProvider();
export async function removeOpenAICodexProvider(): Promise<void> {
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<EligibilityCheckResult> {
export async function checkOpenAICodexEligibility(): Promise<EligibilityCheckResult> {
try {
const balance = await providersRequest<BalanceResponse>(
"GET",
@@ -248,12 +257,12 @@ export async function checkAnthropicOAuthEligibility(): Promise<EligibilityCheck
return {
eligible: false,
billing_tier: balance.billing_tier,
reason: `Claude OAuth requires a Pro or Enterprise plan. Current plan: ${balance.billing_tier}`,
reason: `OpenAI Codex OAuth requires a Pro or Enterprise plan. Current plan: ${balance.billing_tier}`,
};
} catch (error) {
// If we can't check eligibility, allow the flow to continue
// The provider creation will handle the error appropriately
console.warn("Failed to check Anthropic OAuth eligibility:", error);
console.warn("Failed to check OpenAI Codex OAuth eligibility:", error);
return {
eligible: true,
billing_tier: "unknown",

View File

@@ -40,18 +40,12 @@ export interface Settings {
refreshToken?: string; // DEPRECATED: kept for migration, now stored in secrets
tokenExpiresAt?: number; // Unix timestamp in milliseconds
deviceId?: string;
// Anthropic OAuth
anthropicOAuth?: {
access_token: string;
refresh_token?: string;
expires_at: number; // Unix timestamp in milliseconds
scope?: string;
};
// Pending OAuth state (for PKCE flow)
oauthState?: {
state: string;
codeVerifier: string;
provider: "anthropic";
redirectUri: string;
provider: "openai";
timestamp: number;
};
}
@@ -971,84 +965,20 @@ class SettingsManager {
return exists(dirPath);
}
// =====================================================================
// Anthropic OAuth Management
// =====================================================================
/**
* Store Anthropic OAuth tokens
*/
storeAnthropicTokens(tokens: {
access_token: string;
refresh_token?: string;
expires_in: number;
scope?: string;
}): void {
this.updateSettings({
anthropicOAuth: {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: Date.now() + tokens.expires_in * 1000,
scope: tokens.scope,
},
});
}
/**
* Get Anthropic OAuth tokens (returns null if not set or expired)
*/
getAnthropicTokens(): Settings["anthropicOAuth"] | null {
const settings = this.getSettings();
if (!settings.anthropicOAuth) return null;
return settings.anthropicOAuth;
}
/**
* Check if Anthropic OAuth tokens are expired or about to expire
* Returns true if token expires within the next 5 minutes
*/
isAnthropicTokenExpired(): boolean {
const tokens = this.getAnthropicTokens();
if (!tokens) return true;
const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000;
return tokens.expires_at < fiveMinutesFromNow;
}
/**
* Check if Anthropic OAuth is configured
*/
hasAnthropicOAuth(): boolean {
return !!this.getAnthropicTokens();
}
/**
* Clear Anthropic OAuth tokens and state
*/
clearAnthropicOAuth(): void {
const settings = this.getSettings();
const { anthropicOAuth: _, oauthState: __, ...rest } = settings;
this.settings = { ...DEFAULT_SETTINGS, ...rest };
this.persistSettings().catch((error) => {
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(),
},