feat: openrouter byok support through /connect (#735)

This commit is contained in:
Ari Webb
2026-01-28 18:10:23 -08:00
committed by GitHub
parent 2f203b7e08
commit bbfb56ab84
4 changed files with 347 additions and 25 deletions

View File

@@ -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 <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\n \u2022 minimax <api_key> - Connect to MiniMax with your API key\n \u2022 bedrock <method> - Connect to AWS Bedrock (iam/profile/default)",
"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\n \u2022 minimax <api_key> - Connect to MiniMax with your API key\n \u2022 openrouter <api_key> - Connect to OpenRouter with your API key\n \u2022 bedrock <method> - 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 <provider> [options]`,
`Error: Unknown provider "${provider}"\n\nAvailable providers: codex, zai, minimax, openrouter, bedrock\nUsage: /connect <provider> [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 <provider>\n\nAvailable providers: codex, claude, zai, minimax, bedrock",
"Usage: /disconnect <provider>\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 <provider>`,
`Error: Unknown provider "${provider}"\n\nAvailable providers: codex, claude, zai, minimax, openrouter, bedrock\nUsage: /disconnect <provider>`,
false,
);
}
@@ -956,3 +975,135 @@ export async function handleConnectZai(
ctx.setCommandRunning(false);
}
}
/**
* Handle /connect openrouter command
* Usage: /connect openrouter <api_key>
*
* Creates the lc-openrouter provider with the provided API key
*/
export async function handleConnectOpenrouter(
ctx: ConnectCommandContext,
msg: string,
): Promise<void> {
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 <api_key>\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<void> {
// 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 <api_key> 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);
}
}

View File

@@ -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({
<Box flexDirection="row">
<Text color={colors.selector.itemHighlighted}>{"> "}</Text>
<Text>{apiKeyInput ? maskApiKey(apiKeyInput) : "(enter key)"}</Text>
<Text color={statusColor} dimColor={validationState === "validating"}>
<Text
color={statusColor}
dimColor={
validationState === "validating" || validationState === "saving"
}
>
{statusText}
</Text>
</Box>
@@ -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({
<Box marginTop={1}>
<Text
color={statusColor}
dimColor={validationState === "validating"}
dimColor={
validationState === "validating" || validationState === "saving"
}
>
{" "}
{statusText}

View File

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

View File

@@ -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<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
body?: Record<string, unknown>,
): Promise<T> {
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<ProviderResponse[]> {
try {
const response = await providersRequest<ProviderResponse[]>(
"GET",
"/v1/providers",
);
return response;
} catch {
return [];
}
}
/**
* Get the lc-openrouter provider if it exists
*/
export async function getOpenrouterProvider(): Promise<ProviderResponse | null> {
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<ProviderResponse> {
return providersRequest<ProviderResponse>("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<ProviderResponse> {
return providersRequest<ProviderResponse>(
"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<ProviderResponse> {
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<void> {
await providersRequest<void>("DELETE", `/v1/providers/${providerId}`);
}
/**
* Remove the OpenRouter provider (called on /disconnect openrouter)
*/
export async function removeOpenrouterProvider(): Promise<void> {
const existing = await getOpenrouterProvider();
if (existing) {
await deleteOpenrouterProvider(existing.id);
}
}