feat: add AWS Bedrock to /connect (#671)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-24 20:51:18 -08:00
committed by GitHub
parent fd7ca18066
commit 7f8a1f54bf
2 changed files with 267 additions and 3 deletions

View File

@@ -17,6 +17,7 @@ const SOLID_LINE = "─";
type ViewState =
| { type: "list" }
| { type: "input"; provider: ByokProvider }
| { type: "multiInput"; provider: ByokProvider }
| { type: "options"; provider: ByokProvider; providerId: string };
type ValidationState = "idle" | "validating" | "valid" | "invalid";
@@ -46,6 +47,9 @@ export function ProviderSelector({
useState<ValidationState>("idle");
const [validationError, setValidationError] = useState<string | null>(null);
const [optionIndex, setOptionIndex] = useState(0);
// Multi-field input state (for providers like Bedrock)
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const [focusedFieldIndex, setFocusedFieldIndex] = useState(0);
const mountedRef = useRef(true);
useEffect(() => {
@@ -107,8 +111,15 @@ export function ProviderSelector({
setViewState({ type: "options", provider, providerId });
setOptionIndex(0);
}
} else if ("fields" in provider && provider.fields) {
// Multi-field provider (like Bedrock) - show multi-input view
setViewState({ type: "multiInput", provider });
setFieldValues({});
setFocusedFieldIndex(0);
setValidationState("idle");
setValidationError(null);
} else {
// Show API key input for new provider
// Single API key input for regular providers
setViewState({ type: "input", provider });
setApiKeyInput("");
setValidationState("idle");
@@ -171,6 +182,75 @@ export function ProviderSelector({
}
}, [viewState, apiKeyInput, validationState]);
// Handle multi-field validation and saving (for providers like Bedrock)
const handleMultiFieldValidateAndSave = useCallback(async () => {
if (viewState.type !== "multiInput") return;
if (!("fields" in viewState.provider) || !viewState.provider.fields) return;
const { provider } = viewState;
const fields = provider.fields;
// Check all required fields are filled
const allFilled = fields.every((field) => fieldValues[field.key]?.trim());
if (!allFilled) return;
const apiKey = fieldValues.apiKey?.trim() || "";
const accessKey = fieldValues.accessKey?.trim();
const region = fieldValues.region?.trim();
// If already validated, save
if (validationState === "valid") {
try {
await createOrUpdateProvider(
provider.providerType,
provider.providerName,
apiKey,
accessKey,
region,
);
// Refresh connected providers
const providers = await getConnectedProviders();
if (mountedRef.current) {
setConnectedProviders(providers);
setViewState({ type: "list" });
setFieldValues({});
setValidationState("idle");
}
} catch (err) {
if (mountedRef.current) {
setValidationError(
err instanceof Error ? err.message : "Failed to save",
);
setValidationState("invalid");
}
}
return;
}
// Validate the credentials
setValidationState("validating");
setValidationError(null);
try {
await checkProviderApiKey(
provider.providerType,
apiKey,
accessKey,
region,
);
if (mountedRef.current) {
setValidationState("valid");
}
} catch (err) {
if (mountedRef.current) {
setValidationState("invalid");
setValidationError(
err instanceof Error ? err.message : "Invalid credentials",
);
}
}
}, [viewState, fieldValues, validationState]);
// Handle disconnect
const handleDisconnect = useCallback(async () => {
if (viewState.type !== "options") return;
@@ -248,6 +328,54 @@ export function ProviderSelector({
setValidationError(null);
}
}
} else if (viewState.type === "multiInput") {
if (!("fields" in viewState.provider) || !viewState.provider.fields)
return;
const fields = viewState.provider.fields;
const currentField = fields[focusedFieldIndex];
if (!currentField) return;
if (key.escape) {
// Back to list
setViewState({ type: "list" });
setFieldValues({});
setFocusedFieldIndex(0);
setValidationState("idle");
setValidationError(null);
} else if (key.tab) {
// Move to next/prev field
if (key.shift) {
setFocusedFieldIndex((prev) => Math.max(0, prev - 1));
} else {
setFocusedFieldIndex((prev) => Math.min(fields.length - 1, prev + 1));
}
} else if (key.upArrow) {
setFocusedFieldIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setFocusedFieldIndex((prev) => Math.min(fields.length - 1, prev + 1));
} else if (key.return) {
handleMultiFieldValidateAndSave();
} else if (key.backspace || key.delete) {
setFieldValues((prev) => ({
...prev,
[currentField.key]: (prev[currentField.key] || "").slice(0, -1),
}));
// Reset validation if value changed
if (validationState !== "idle") {
setValidationState("idle");
setValidationError(null);
}
} else if (input && !key.ctrl && !key.meta) {
setFieldValues((prev) => ({
...prev,
[currentField.key]: (prev[currentField.key] || "") + input,
}));
// Reset validation if value changed
if (validationState !== "idle") {
setValidationState("idle");
setValidationError(null);
}
}
} else if (viewState.type === "options") {
const options = ["Update API key", "Disconnect", "Back"];
if (key.escape) {
@@ -389,6 +517,105 @@ export function ProviderSelector({
);
};
// Render multi-input view (for providers like Bedrock)
const renderMultiInputView = () => {
if (viewState.type !== "multiInput") return null;
if (!("fields" in viewState.provider) || !viewState.provider.fields)
return null;
const { provider } = viewState;
const fields = provider.fields;
// Check if all fields are filled
const allFilled = fields.every((field) => fieldValues[field.key]?.trim());
const statusText =
validationState === "validating"
? " (validating...)"
: validationState === "valid"
? " (credentials validated!)"
: validationState === "invalid"
? ` (invalid${validationError ? `: ${validationError}` : ""})`
: "";
const statusColor =
validationState === "valid"
? "green"
: validationState === "invalid"
? "red"
: undefined;
const footerText =
validationState === "valid"
? "Enter to save · Esc cancel"
: allFilled
? "Enter to validate · Tab/↑↓ navigate · Esc cancel"
: "Tab/↑↓ navigate · Esc cancel";
return (
<>
<Box flexDirection="column" marginBottom={1}>
<Text bold color={colors.selector.title}>
Connect {provider.displayName}
</Text>
</Box>
<Box flexDirection="column">
{fields.map((field, index) => {
const isFocused = index === focusedFieldIndex;
const value = fieldValues[field.key] || "";
const displayValue = field.secret ? maskApiKey(value) : value;
return (
<Box key={field.key} flexDirection="row">
<Text
color={
isFocused ? colors.selector.itemHighlighted : undefined
}
>
{isFocused ? "> " : " "}
</Text>
<Text dimColor={!isFocused} bold={isFocused}>
{field.label}:
</Text>
<Text> </Text>
<Text
color={
isFocused ? colors.selector.itemHighlighted : undefined
}
>
{displayValue ||
(isFocused
? `(${field.placeholder || "enter value"})`
: "")}
</Text>
</Box>
);
})}
</Box>
{(validationState !== "idle" || validationError) && (
<Box marginTop={1}>
<Text
color={statusColor}
dimColor={validationState === "validating"}
>
{" "}
{statusText}
</Text>
</Box>
)}
<Box marginTop={1}>
<Text dimColor>
{" "}
{footerText}
</Text>
</Box>
</>
);
};
// Render options view (for connected providers)
const renderOptionsView = () => {
if (viewState.type !== "options") return null;
@@ -450,6 +677,7 @@ export function ProviderSelector({
{viewState.type === "list" && renderListView()}
{viewState.type === "input" && renderInputView()}
{viewState.type === "multiInput" && renderMultiInputView()}
{viewState.type === "options" && renderOptionsView()}
</Box>
);

View File

@@ -6,6 +6,14 @@
import { LETTA_CLOUD_API_URL } from "../auth/oauth";
import { settingsManager } from "../settings-manager";
// Field definition for multi-field providers (like Bedrock)
export interface ProviderField {
key: string;
label: string;
placeholder?: string;
secret?: boolean; // If true, mask input like a password
}
// Provider configuration for the /connect UI
export const BYOK_PROVIDERS = [
{
@@ -44,6 +52,18 @@ export const BYOK_PROVIDERS = [
providerType: "google_ai",
providerName: "lc-gemini",
},
{
id: "bedrock",
displayName: "AWS Bedrock",
description: "Connect to Claude on Amazon Bedrock",
providerType: "bedrock",
providerName: "lc-bedrock",
fields: [
{ key: "accessKey", label: "AWS Access Key ID", placeholder: "AKIA..." },
{ key: "apiKey", label: "AWS Secret Access Key", secret: true },
{ key: "region", label: "AWS Region", placeholder: "us-east-1" },
] as ProviderField[],
},
] as const;
export type ByokProviderId = (typeof BYOK_PROVIDERS)[number]["id"];
@@ -56,6 +76,8 @@ export interface ProviderResponse {
provider_type: string;
api_key?: string;
base_url?: string;
access_key?: string;
region?: string;
}
/**
@@ -161,10 +183,14 @@ export async function getProviderByName(
export async function checkProviderApiKey(
providerType: string,
apiKey: string,
accessKey?: string,
region?: string,
): Promise<void> {
await providersRequest<{ message: string }>("POST", "/v1/providers/check", {
provider_type: providerType,
api_key: apiKey,
...(accessKey && { access_key: accessKey }),
...(region && { region }),
});
}
@@ -175,11 +201,15 @@ export async function createProvider(
providerType: string,
providerName: string,
apiKey: string,
accessKey?: string,
region?: string,
): Promise<ProviderResponse> {
return providersRequest<ProviderResponse>("POST", "/v1/providers", {
name: providerName,
provider_type: providerType,
api_key: apiKey,
...(accessKey && { access_key: accessKey }),
...(region && { region }),
});
}
@@ -189,12 +219,16 @@ export async function createProvider(
export async function updateProvider(
providerId: string,
apiKey: string,
accessKey?: string,
region?: string,
): Promise<ProviderResponse> {
return providersRequest<ProviderResponse>(
"PATCH",
`/v1/providers/${providerId}`,
{
api_key: apiKey,
...(accessKey && { access_key: accessKey }),
...(region && { region }),
},
);
}
@@ -214,14 +248,16 @@ export async function createOrUpdateProvider(
providerType: string,
providerName: string,
apiKey: string,
accessKey?: string,
region?: string,
): Promise<ProviderResponse> {
const existing = await getProviderByName(providerName);
if (existing) {
return updateProvider(existing.id, apiKey);
return updateProvider(existing.id, apiKey, accessKey, region);
}
return createProvider(providerType, providerName, apiKey);
return createProvider(providerType, providerName, apiKey, accessKey, region);
}
/**