feat: replace /connect claude with /connect codex for OpenAI OAuth (#527)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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