feat: openrouter byok support through /connect (#735)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
147
src/providers/openrouter-provider.ts
Normal file
147
src/providers/openrouter-provider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user