feat(connect): unify provider connect flows across TUI and CLI (#1243)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-03-03 13:26:26 -08:00
committed by GitHub
parent be7606824b
commit d038695244
11 changed files with 1258 additions and 712 deletions

View File

@@ -7103,8 +7103,8 @@ export default function App({
return { submitted: true };
}
// /connect codex - direct OAuth flow (kept for backwards compatibility)
if (msg.trim().startsWith("/connect codex")) {
// /connect <provider> - direct CLI-style provider flow
if (msg.trim().startsWith("/connect ")) {
const cmd = commandRunner.start(msg, "Starting connection...");
const {
handleConnect,
@@ -13147,7 +13147,7 @@ If using apply_patch, use this exact relative patch path: ${applyPatchRelativePa
setActiveOverlay("model");
},
},
"/connect codex",
"/connect chatgpt",
);
} finally {
setActiveConnectCommandId(null);

View File

@@ -0,0 +1,115 @@
import {
type ByokProvider,
type ByokProviderId,
getProviderConfig,
} from "../../providers/byok-providers";
export type ConnectProviderCanonical =
| "chatgpt"
| "anthropic"
| "openai"
| "zai"
| "minimax"
| "gemini"
| "openrouter"
| "bedrock";
const ALIAS_TO_CANONICAL: Record<string, ConnectProviderCanonical> = {
chatgpt: "chatgpt",
codex: "chatgpt",
anthropic: "anthropic",
openai: "openai",
zai: "zai",
minimax: "minimax",
gemini: "gemini",
openrouter: "openrouter",
bedrock: "bedrock",
};
const CANONICAL_ORDER: ConnectProviderCanonical[] = [
"chatgpt",
"anthropic",
"openai",
"zai",
"minimax",
"gemini",
"openrouter",
"bedrock",
];
function canonicalToByokId(
canonical: ConnectProviderCanonical,
): ByokProviderId {
return canonical === "chatgpt" ? "codex" : canonical;
}
export interface ResolvedConnectProvider {
rawInput: string;
canonical: ConnectProviderCanonical;
byokId: ByokProviderId;
byokProvider: ByokProvider;
}
export function resolveConnectProvider(
providerToken: string | undefined,
): ResolvedConnectProvider | null {
if (!providerToken) {
return null;
}
const rawInput = providerToken.trim().toLowerCase();
if (!rawInput) {
return null;
}
const canonical = ALIAS_TO_CANONICAL[rawInput];
if (!canonical) {
return null;
}
const byokId = canonicalToByokId(canonical);
const byokProvider = getProviderConfig(byokId);
if (!byokProvider) {
return null;
}
return {
rawInput,
canonical,
byokId,
byokProvider,
};
}
export function listConnectProvidersForHelp(): string[] {
return CANONICAL_ORDER.map((provider) => {
if (provider === "chatgpt") {
return "chatgpt (alias: codex)";
}
return provider;
});
}
export function listConnectProviderTokens(): string[] {
return [...CANONICAL_ORDER, "codex"];
}
export function isConnectOAuthProvider(
provider: ResolvedConnectProvider,
): boolean {
return provider.canonical === "chatgpt";
}
export function isConnectBedrockProvider(
provider: ResolvedConnectProvider,
): boolean {
return provider.canonical === "bedrock";
}
export function isConnectApiKeyProvider(
provider: ResolvedConnectProvider,
): boolean {
return (
!isConnectOAuthProvider(provider) && !isConnectBedrockProvider(provider)
);
}

View File

@@ -0,0 +1,169 @@
import {
exchangeCodeForTokens,
extractAccountIdFromToken,
OPENAI_OAUTH_CONFIG,
type OpenAITokens,
startLocalOAuthServer,
startOpenAIOAuth,
} from "../../auth/openai-oauth";
import {
type ChatGPTOAuthConfig,
createOrUpdateOpenAICodexProvider,
getOpenAICodexProvider,
OPENAI_CODEX_PROVIDER_NAME,
} from "../../providers/openai-codex-provider";
import { settingsManager } from "../../settings-manager";
interface OAuthCodeServerResult {
result: {
code: string;
state: string;
};
server: {
close: () => void;
};
}
interface OAuthStartResult {
authorizationUrl: string;
state: string;
codeVerifier: string;
redirectUri: string;
}
interface OAuthFlowDeps {
startOAuth: (port?: number) => Promise<OAuthStartResult>;
startCallbackServer: (
expectedState: string,
port?: number,
) => Promise<OAuthCodeServerResult>;
exchangeTokens: (
code: string,
codeVerifier: string,
redirectUri: string,
) => Promise<OpenAITokens>;
extractAccountId: (accessToken: string) => string;
createOrUpdateProvider: (config: ChatGPTOAuthConfig) => Promise<unknown>;
getProvider: () => Promise<unknown>;
storeOAuthState: typeof settingsManager.storeOAuthState;
clearOAuthState: typeof settingsManager.clearOAuthState;
}
const DEFAULT_DEPS: OAuthFlowDeps = {
startOAuth: (port?: number) =>
startOpenAIOAuth(
(port as typeof OPENAI_OAUTH_CONFIG.defaultPort | undefined) ??
OPENAI_OAUTH_CONFIG.defaultPort,
),
startCallbackServer: (expectedState: string, port?: number) =>
startLocalOAuthServer(
expectedState,
(port as typeof OPENAI_OAUTH_CONFIG.defaultPort | undefined) ??
OPENAI_OAUTH_CONFIG.defaultPort,
),
exchangeTokens: exchangeCodeForTokens,
extractAccountId: extractAccountIdFromToken,
createOrUpdateProvider: createOrUpdateOpenAICodexProvider,
getProvider: getOpenAICodexProvider,
storeOAuthState: (...args) => settingsManager.storeOAuthState(...args),
clearOAuthState: () => settingsManager.clearOAuthState(),
};
export interface ChatGPTOAuthFlowCallbacks {
onStatus: (message: string) => void | Promise<void>;
openBrowser?: (authorizationUrl: string) => Promise<void>;
}
export async function openOAuthBrowser(
authorizationUrl: string,
): Promise<void> {
try {
const { default: open } = await import("open");
const subprocess = await open(authorizationUrl, { wait: false });
subprocess.on("error", () => {
// Ignore browser launch errors. The user can still open the URL manually.
});
} catch {
// Ignore browser launch failures. The user can still open the URL manually.
}
}
export async function isChatGPTOAuthConnected(
deps: Partial<OAuthFlowDeps> = {},
): Promise<boolean> {
const mergedDeps = { ...DEFAULT_DEPS, ...deps };
const existing = await mergedDeps.getProvider();
return Boolean(existing);
}
export async function runChatGPTOAuthConnectFlow(
callbacks: ChatGPTOAuthFlowCallbacks,
deps: Partial<OAuthFlowDeps> = {},
): Promise<{ providerName: string }> {
const mergedDeps = { ...DEFAULT_DEPS, ...deps };
const browserOpener = callbacks.openBrowser ?? openOAuthBrowser;
await callbacks.onStatus("Checking account eligibility...");
try {
await callbacks.onStatus(
"Starting OAuth flow...\nA browser window will open for authorization.",
);
const { authorizationUrl, state, codeVerifier, redirectUri } =
await mergedDeps.startOAuth(OPENAI_OAUTH_CONFIG.defaultPort);
mergedDeps.storeOAuthState(state, codeVerifier, redirectUri, "openai");
await callbacks.onStatus(
`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}`,
);
const serverPromise = mergedDeps.startCallbackServer(
state,
OPENAI_OAUTH_CONFIG.defaultPort,
);
await browserOpener(authorizationUrl);
await callbacks.onStatus(
"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}`,
);
const { result, server } = await serverPromise;
server.close();
await callbacks.onStatus(
"Authorization received! Exchanging code for tokens...",
);
const tokens = await mergedDeps.exchangeTokens(
result.code,
codeVerifier,
redirectUri,
);
await callbacks.onStatus("Extracting account information...");
const accountId = mergedDeps.extractAccountId(tokens.access_token);
await callbacks.onStatus("Creating ChatGPT OAuth provider...");
await mergedDeps.createOrUpdateProvider({
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,
});
mergedDeps.clearOAuthState();
return { providerName: OPENAI_CODEX_PROVIDER_NAME };
} catch (error) {
mergedDeps.clearOAuthState();
throw error;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,306 @@
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import { parseArgs } from "node:util";
import {
checkProviderApiKey,
createOrUpdateProvider,
} from "../../providers/byok-providers";
import { getErrorMessage } from "../../utils/error";
import {
isConnectApiKeyProvider,
isConnectBedrockProvider,
isConnectOAuthProvider,
listConnectProvidersForHelp,
listConnectProviderTokens,
resolveConnectProvider,
} from "../commands/connect-normalize";
import {
type ChatGPTOAuthFlowCallbacks,
isChatGPTOAuthConnected,
runChatGPTOAuthConnectFlow,
} from "../commands/connect-oauth-core";
const CONNECT_OPTIONS = {
help: { type: "boolean", short: "h" },
"api-key": { type: "string" },
method: { type: "string" },
"access-key": { type: "string" },
"secret-key": { type: "string" },
region: { type: "string" },
profile: { type: "string" },
} as const;
interface ConnectSubcommandDeps {
stdout: (message: string) => void;
stderr: (message: string) => void;
isTTY: () => boolean;
promptSecret: (label: string) => Promise<string>;
checkProviderApiKey: (
providerType: string,
apiKey: string,
accessKey?: string,
region?: string,
profile?: string,
) => Promise<void>;
createOrUpdateProvider: (
providerType: string,
providerName: string,
apiKey: string,
accessKey?: string,
region?: string,
profile?: string,
) => Promise<unknown>;
isChatGPTOAuthConnected: () => Promise<boolean>;
runChatGPTOAuthConnectFlow: (
callbacks: ChatGPTOAuthFlowCallbacks,
) => Promise<unknown>;
}
function readStringOption(
value: string | boolean | (string | boolean)[] | undefined,
): string | undefined {
if (typeof value === "string") {
return value;
}
return undefined;
}
const DEFAULT_DEPS: ConnectSubcommandDeps = {
stdout: (message) => console.log(message),
stderr: (message) => console.error(message),
isTTY: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
promptSecret: promptSecret,
checkProviderApiKey,
createOrUpdateProvider,
isChatGPTOAuthConnected,
runChatGPTOAuthConnectFlow,
};
function formatUsage(): string {
return [
"Usage:",
" letta connect <provider> [options]",
"",
"Providers:",
` ${listConnectProvidersForHelp().join("\n ")}`,
"",
"Examples:",
" letta connect chatgpt",
" letta connect codex",
" letta connect anthropic <api_key>",
" letta connect openai --api-key <api_key>",
" letta connect bedrock --method iam --access-key <id> --secret-key <key> --region <region>",
" letta connect bedrock --method profile --profile <name> --region <region>",
].join("\n");
}
function formatBedrockUsage(): string {
return [
"Usage: letta connect bedrock [--method iam|profile] [options]",
"",
"IAM method:",
" --method iam --access-key <id> --secret-key <key> --region <region>",
"",
"Profile method:",
" --method profile --profile <name> --region <region>",
].join("\n");
}
async function promptSecret(promptLabel: string): Promise<string> {
class MutedWritable extends Writable {
muted = false;
override _write(
chunk: Buffer | string,
encoding: BufferEncoding,
callback: (error?: Error | null) => void,
): void {
if (!this.muted) {
process.stdout.write(chunk, encoding);
}
callback();
}
}
const mutedOutput = new MutedWritable();
const rl = createInterface({
input: process.stdin,
output: mutedOutput,
terminal: true,
});
try {
process.stdout.write(promptLabel);
mutedOutput.muted = true;
const answer = await rl.question("");
process.stdout.write("\n");
return answer.trim();
} finally {
mutedOutput.muted = false;
rl.close();
}
}
export async function runConnectSubcommand(
argv: string[],
deps: Partial<ConnectSubcommandDeps> = {},
): Promise<number> {
const io = { ...DEFAULT_DEPS, ...deps };
let parsed: ReturnType<typeof parseArgs>;
try {
parsed = parseArgs({
args: argv,
options: CONNECT_OPTIONS,
strict: true,
allowPositionals: true,
});
} catch (error) {
io.stderr(error instanceof Error ? error.message : String(error));
io.stdout(formatUsage());
return 1;
}
const [providerToken, ...restPositionals] = parsed.positionals;
if (parsed.values.help || !providerToken || providerToken === "help") {
io.stdout(formatUsage());
return 0;
}
const provider = resolveConnectProvider(providerToken);
if (!provider) {
io.stderr(
`Unknown provider: ${providerToken}. Supported providers: ${listConnectProviderTokens().join(", ")}`,
);
return 1;
}
if (isConnectOAuthProvider(provider)) {
try {
if (await io.isChatGPTOAuthConnected()) {
io.stdout(
"Already connected to ChatGPT via OAuth. Disconnect first if you want to re-authenticate.",
);
return 0;
}
await io.runChatGPTOAuthConnectFlow({
onStatus: (status) => io.stdout(status),
});
io.stdout("Successfully connected to ChatGPT OAuth.");
return 0;
} catch (error) {
io.stderr(`Failed to connect ChatGPT OAuth: ${getErrorMessage(error)}`);
return 1;
}
}
if (isConnectBedrockProvider(provider)) {
const method = (
readStringOption(parsed.values.method) ??
restPositionals[0] ??
""
).toLowerCase();
const accessKey = readStringOption(parsed.values["access-key"]) ?? "";
const secretKey = readStringOption(parsed.values["secret-key"]) ?? "";
const region = readStringOption(parsed.values.region) ?? "";
const profile = readStringOption(parsed.values.profile) ?? "";
if (!method || (method !== "iam" && method !== "profile")) {
io.stderr("Bedrock method must be `iam` or `profile`.");
io.stdout(formatBedrockUsage());
return 1;
}
if (method === "iam" && (!accessKey || !secretKey || !region)) {
io.stderr(
"Missing IAM fields. Required: --access-key, --secret-key, --region.",
);
io.stdout(formatBedrockUsage());
return 1;
}
if (method === "profile" && (!profile || !region)) {
io.stderr("Missing profile fields. Required: --profile and --region.");
io.stdout(formatBedrockUsage());
return 1;
}
try {
io.stdout("Validating AWS Bedrock credentials...");
await io.checkProviderApiKey(
provider.byokProvider.providerType,
method === "iam" ? secretKey : "",
method === "iam" ? accessKey : undefined,
region,
method === "profile" ? profile : undefined,
);
io.stdout("Saving provider...");
await io.createOrUpdateProvider(
provider.byokProvider.providerType,
provider.byokProvider.providerName,
method === "iam" ? secretKey : "",
method === "iam" ? accessKey : undefined,
region,
method === "profile" ? profile : undefined,
);
io.stdout(
`Connected ${provider.byokProvider.displayName} (${provider.byokProvider.providerName}).`,
);
return 0;
} catch (error) {
io.stderr(`Failed to connect bedrock: ${getErrorMessage(error)}`);
return 1;
}
}
if (isConnectApiKeyProvider(provider)) {
let apiKey =
readStringOption(parsed.values["api-key"]) ?? restPositionals[0] ?? "";
if (!apiKey) {
if (!io.isTTY()) {
io.stderr(
`Missing API key for ${provider.canonical}. Pass as positional arg or --api-key.`,
);
return 1;
}
apiKey = await io.promptSecret(
`${provider.byokProvider.displayName} API key: `,
);
}
if (!apiKey) {
io.stderr("API key cannot be empty.");
return 1;
}
try {
io.stdout(`Validating ${provider.byokProvider.displayName} API key...`);
await io.checkProviderApiKey(provider.byokProvider.providerType, apiKey);
io.stdout("Saving provider...");
await io.createOrUpdateProvider(
provider.byokProvider.providerType,
provider.byokProvider.providerName,
apiKey,
);
io.stdout(
`Connected ${provider.byokProvider.displayName} (${provider.byokProvider.providerName}).`,
);
return 0;
} catch (error) {
io.stderr(
`Failed to connect ${provider.byokProvider.displayName}: ${getErrorMessage(error)}`,
);
return 1;
}
}
io.stderr("Unsupported provider configuration.");
return 1;
}

View File

@@ -1,5 +1,6 @@
import { runAgentsSubcommand } from "./agents";
import { runBlocksSubcommand } from "./blocks";
import { runConnectSubcommand } from "./connect";
import { runListenSubcommand } from "./listen.tsx";
import { runMemfsSubcommand } from "./memfs";
import { runMessagesSubcommand } from "./messages";
@@ -22,6 +23,8 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
return runBlocksSubcommand(rest);
case "remote":
return runListenSubcommand(rest);
case "connect":
return runConnectSubcommand(rest);
default:
return null;
}