From bbfb56ab84c848c7c41983a0993da3feab4789a4 Mon Sep 17 00:00:00 2001 From: Ari Webb Date: Wed, 28 Jan 2026 18:10:23 -0800 Subject: [PATCH] feat: openrouter byok support through /connect (#735) --- src/cli/commands/connect.ts | 159 +++++++++++++++++++++++- src/cli/components/ProviderSelector.tsx | 59 +++++---- src/providers/byok-providers.ts | 7 ++ src/providers/openrouter-provider.ts | 147 ++++++++++++++++++++++ 4 files changed, 347 insertions(+), 25 deletions(-) create mode 100644 src/providers/openrouter-provider.ts diff --git a/src/cli/commands/connect.ts b/src/cli/commands/connect.ts index b12565b..30c0c64 100644 --- a/src/cli/commands/connect.ts +++ b/src/cli/commands/connect.ts @@ -25,6 +25,12 @@ import { OPENAI_CODEX_PROVIDER_NAME, removeOpenAICodexProvider, } from "../../providers/openai-codex-provider"; +import { + createOrUpdateOpenrouterProvider, + getOpenrouterProvider, + OPENROUTER_PROVIDER_NAME, + removeOpenrouterProvider, +} from "../../providers/openrouter-provider"; import { createOrUpdateZaiProvider, getZaiProvider, @@ -120,7 +126,7 @@ export async function handleConnect( ctx.buffersRef, ctx.refreshDerived, msg, - "Usage: /connect [options]\n\nAvailable providers:\n \u2022 codex - Connect via OAuth to authenticate with ChatGPT Plus/Pro\n \u2022 zai - Connect to zAI with your API key\n \u2022 minimax - Connect to MiniMax with your API key\n \u2022 bedrock - Connect to AWS Bedrock (iam/profile/default)", + "Usage: /connect [options]\n\nAvailable providers:\n \u2022 codex - Connect via OAuth to authenticate with ChatGPT Plus/Pro\n \u2022 zai - Connect to zAI with your API key\n \u2022 minimax - Connect to MiniMax with your API key\n \u2022 openrouter - Connect to OpenRouter with your API key\n \u2022 bedrock - Connect to AWS Bedrock (iam/profile/default)", false, ); return; @@ -130,13 +136,14 @@ export async function handleConnect( provider !== "codex" && provider !== "zai" && provider !== "minimax" && + provider !== "openrouter" && provider !== "bedrock" ) { addCommandResult( ctx.buffersRef, ctx.refreshDerived, msg, - `Error: Unknown provider "${provider}"\n\nAvailable providers: codex, zai, minimax, bedrock\nUsage: /connect [options]`, + `Error: Unknown provider "${provider}"\n\nAvailable providers: codex, zai, minimax, openrouter, bedrock\nUsage: /connect [options]`, false, ); return; @@ -154,6 +161,12 @@ export async function handleConnect( return; } + // OpenRouter is handled here + if (provider === "openrouter") { + await handleConnectOpenrouter(ctx, msg); + return; + } + // Bedrock is handled here if (provider === "bedrock") { await handleConnectBedrock(ctx, msg); @@ -631,7 +644,7 @@ export async function handleDisconnect( ctx.buffersRef, ctx.refreshDerived, msg, - "Usage: /disconnect \n\nAvailable providers: codex, claude, zai, minimax, bedrock", + "Usage: /disconnect \n\nAvailable providers: codex, claude, zai, minimax, openrouter, bedrock", false, ); return; @@ -649,6 +662,12 @@ export async function handleDisconnect( return; } + // Handle /disconnect openrouter + if (provider === "openrouter") { + await handleDisconnectOpenrouter(ctx, msg); + return; + } + // Handle /disconnect bedrock if (provider === "bedrock") { await handleDisconnectBedrock(ctx, msg); @@ -672,7 +691,7 @@ export async function handleDisconnect( ctx.buffersRef, ctx.refreshDerived, msg, - `Error: Unknown provider "${provider}"\n\nAvailable providers: codex, claude, zai, minimax, bedrock\nUsage: /disconnect `, + `Error: Unknown provider "${provider}"\n\nAvailable providers: codex, claude, zai, minimax, openrouter, bedrock\nUsage: /disconnect `, false, ); } @@ -956,3 +975,135 @@ export async function handleConnectZai( ctx.setCommandRunning(false); } } + +/** + * Handle /connect openrouter command + * Usage: /connect openrouter + * + * Creates the lc-openrouter provider with the provided API key + */ +export async function handleConnectOpenrouter( + ctx: ConnectCommandContext, + msg: string, +): Promise { + const parts = msg.trim().split(/\s+/); + // Join all remaining parts in case the API key got split + const apiKey = parts.slice(2).join(""); + + // If no API key provided, show usage + if (!apiKey || apiKey.length === 0) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Usage: /connect openrouter \n\n" + + "Connect to OpenRouter by providing your API key.\n\n" + + "Get your API key at https://openrouter.ai/keys\n\n" + + "Example: /connect openrouter sk-or-v1-...", + false, + ); + return; + } + + // Show running status + const cmdId = addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Creating OpenRouter provider...", + true, + "running", + ); + + ctx.setCommandRunning(true); + + try { + // Create or update the OpenRouter provider with the API key + await createOrUpdateOpenrouterProvider(apiKey); + + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `\u2713 Successfully connected to OpenRouter!\n\n` + + `Provider '${OPENROUTER_PROVIDER_NAME}' created in Letta.\n\n` + + `The models are populated in /model \u2192 "All Available Models"`, + true, + "finished", + ); + } catch (error) { + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `\u2717 Failed to create OpenRouter provider: ${getErrorMessage(error)}`, + false, + "finished", + ); + } finally { + ctx.setCommandRunning(false); + } +} + +/** + * Handle /disconnect openrouter + */ +async function handleDisconnectOpenrouter( + ctx: ConnectCommandContext, + msg: string, +): Promise { + // Check if OpenRouter provider exists + const existing = await getOpenrouterProvider(); + if (!existing) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Not currently connected to OpenRouter.\n\nUse /connect openrouter to connect.", + false, + ); + return; + } + + // Show running status + const cmdId = addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Disconnecting from OpenRouter...", + true, + "running", + ); + + ctx.setCommandRunning(true); + + try { + // Remove provider from Letta + await removeOpenrouterProvider(); + + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `\u2713 Disconnected from OpenRouter.\n\n` + + `Provider '${OPENROUTER_PROVIDER_NAME}' removed from Letta.`, + true, + "finished", + ); + } catch (error) { + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `\u2717 Failed to disconnect from OpenRouter: ${getErrorMessage(error)}`, + false, + "finished", + ); + } finally { + ctx.setCommandRunning(false); + } +} diff --git a/src/cli/components/ProviderSelector.tsx b/src/cli/components/ProviderSelector.tsx index 158efb7..609b5c0 100644 --- a/src/cli/components/ProviderSelector.tsx +++ b/src/cli/components/ProviderSelector.tsx @@ -28,7 +28,7 @@ type ViewState = | { type: "profileSelect"; provider: ByokProvider } | { type: "options"; provider: ByokProvider; providerId: string }; -type ValidationState = "idle" | "validating" | "valid" | "invalid"; +type ValidationState = "idle" | "validating" | "valid" | "invalid" | "saving"; interface ProviderSelectorProps { onCancel: () => void; @@ -219,6 +219,7 @@ export function ProviderSelector({ // If already validated, save if (validationState === "valid") { + setValidationState("saving"); try { await createOrUpdateProvider( provider.providerType, @@ -285,6 +286,7 @@ export function ProviderSelector({ // If already validated, save if (validationState === "valid") { + setValidationState("saving"); try { await createOrUpdateProvider( provider.providerType, @@ -603,11 +605,13 @@ export function ProviderSelector({ const statusText = validationState === "validating" ? " (validating...)" - : validationState === "valid" - ? " (key validated!)" - : validationState === "invalid" - ? ` (invalid key${validationError ? `: ${validationError}` : ""})` - : ""; + : validationState === "saving" + ? " (saving & syncing models...)" + : validationState === "valid" + ? " (key validated!)" + : validationState === "invalid" + ? ` (invalid key${validationError ? `: ${validationError}` : ""})` + : ""; const statusColor = validationState === "valid" @@ -617,9 +621,11 @@ export function ProviderSelector({ : undefined; const footerText = - validationState === "valid" - ? "Enter to save · Esc cancel" - : "Enter to validate · Esc cancel"; + validationState === "saving" + ? "Saving provider..." + : validationState === "valid" + ? "Enter to save · Esc cancel" + : "Enter to validate · Esc cancel"; return ( <> @@ -632,7 +638,12 @@ export function ProviderSelector({ {"> "} {apiKeyInput ? maskApiKey(apiKeyInput) : "(enter key)"} - + {statusText} @@ -810,11 +821,13 @@ export function ProviderSelector({ const statusText = validationState === "validating" ? " (validating...)" - : validationState === "valid" - ? " (credentials validated!)" - : validationState === "invalid" - ? ` (invalid${validationError ? `: ${validationError}` : ""})` - : ""; + : validationState === "saving" + ? " (saving & syncing models...)" + : validationState === "valid" + ? " (credentials validated!)" + : validationState === "invalid" + ? ` (invalid${validationError ? `: ${validationError}` : ""})` + : ""; const statusColor = validationState === "valid" @@ -826,11 +839,13 @@ export function ProviderSelector({ const hasAuthMethods = "authMethods" in provider && provider.authMethods; const escText = hasAuthMethods ? "Esc back" : "Esc cancel"; const footerText = - validationState === "valid" - ? `Enter to save · ${escText}` - : allFilled - ? `Enter to validate · Tab/↑↓ navigate · ${escText}` - : `Tab/↑↓ navigate · ${escText}`; + validationState === "saving" + ? "Saving provider..." + : validationState === "valid" + ? `Enter to save · ${escText}` + : allFilled + ? `Enter to validate · Tab/↑↓ navigate · ${escText}` + : `Tab/↑↓ navigate · ${escText}`; // Build title - include auth method name if present const title = authMethod @@ -883,7 +898,9 @@ export function ProviderSelector({ {" "} {statusText} diff --git a/src/providers/byok-providers.ts b/src/providers/byok-providers.ts index ff0e08e..7a3f27b 100644 --- a/src/providers/byok-providers.ts +++ b/src/providers/byok-providers.ts @@ -68,6 +68,13 @@ export const BYOK_PROVIDERS = [ providerType: "google_ai", providerName: "lc-gemini", }, + { + id: "openrouter", + displayName: "OpenRouter API", + description: "Connect an OpenRouter API key", + providerType: "openrouter", + providerName: "lc-openrouter", + }, { id: "bedrock", displayName: "AWS Bedrock", diff --git a/src/providers/openrouter-provider.ts b/src/providers/openrouter-provider.ts new file mode 100644 index 0000000..669c2c4 --- /dev/null +++ b/src/providers/openrouter-provider.ts @@ -0,0 +1,147 @@ +/** + * Direct API calls to Letta for managing OpenRouter provider + */ + +import { getLettaCodeHeaders } from "../agent/http-headers"; +import { LETTA_CLOUD_API_URL } from "../auth/oauth"; +import { settingsManager } from "../settings-manager"; + +// Provider name constant for OpenRouter +export const OPENROUTER_PROVIDER_NAME = "lc-openrouter"; + +interface ProviderResponse { + id: string; + name: string; + provider_type: string; + api_key?: string; + base_url?: string; +} + +/** + * Get the Letta API base URL and auth token + */ +async function getLettaConfig(): Promise<{ baseUrl: string; apiKey: string }> { + const settings = await settingsManager.getSettingsWithSecureTokens(); + const baseUrl = + process.env.LETTA_BASE_URL || + settings.env?.LETTA_BASE_URL || + LETTA_CLOUD_API_URL; + const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY || ""; + return { baseUrl, apiKey }; +} + +/** + * Make a request to the Letta providers API + */ +async function providersRequest( + method: "GET" | "POST" | "PATCH" | "DELETE", + path: string, + body?: Record, +): Promise { + const { baseUrl, apiKey } = await getLettaConfig(); + const url = `${baseUrl}${path}`; + + const response = await fetch(url, { + method, + headers: getLettaCodeHeaders(apiKey), + ...(body && { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Provider API error (${response.status}): ${errorText}`); + } + + // Handle empty responses (e.g., DELETE) + const text = await response.text(); + if (!text) { + return {} as T; + } + return JSON.parse(text) as T; +} + +/** + * List all providers + */ +async function listProviders(): Promise { + try { + const response = await providersRequest( + "GET", + "/v1/providers", + ); + return response; + } catch { + return []; + } +} + +/** + * Get the lc-openrouter provider if it exists + */ +export async function getOpenrouterProvider(): Promise { + const providers = await listProviders(); + return providers.find((p) => p.name === OPENROUTER_PROVIDER_NAME) || null; +} + +/** + * Create the OpenRouter provider with the given API key + */ +export async function createOpenrouterProvider( + apiKey: string, +): Promise { + return providersRequest("POST", "/v1/providers", { + name: OPENROUTER_PROVIDER_NAME, + provider_type: "openrouter", + api_key: apiKey, + }); +} + +/** + * Update an existing OpenRouter provider with a new API key + */ +export async function updateOpenrouterProvider( + providerId: string, + apiKey: string, +): Promise { + return providersRequest( + "PATCH", + `/v1/providers/${providerId}`, + { + api_key: apiKey, + }, + ); +} + +/** + * Create or update the OpenRouter provider + * If provider exists, updates it with the new API key + * If not, creates a new provider + */ +export async function createOrUpdateOpenrouterProvider( + apiKey: string, +): Promise { + const existing = await getOpenrouterProvider(); + + if (existing) { + return updateOpenrouterProvider(existing.id, apiKey); + } + + return createOpenrouterProvider(apiKey); +} + +/** + * Delete the OpenRouter provider by ID + */ +async function deleteOpenrouterProvider(providerId: string): Promise { + await providersRequest("DELETE", `/v1/providers/${providerId}`); +} + +/** + * Remove the OpenRouter provider (called on /disconnect openrouter) + */ +export async function removeOpenrouterProvider(): Promise { + const existing = await getOpenrouterProvider(); + if (existing) { + await deleteOpenrouterProvider(existing.id); + } +}