feat: replace /connect claude with /connect codex for OpenAI OAuth (#527)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}>> </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";
|
||||
Reference in New Issue
Block a user