feat(lighthouse): Support Amazon Bedrock Long-Term API Key (#9343)

Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com>
This commit is contained in:
Chandrapal Badshah
2025-12-03 20:49:18 +05:30
committed by GitHub
parent cb84bd0f94
commit 26fd7d3adc
14 changed files with 1765 additions and 658 deletions

View File

@@ -14,21 +14,47 @@ import type { LighthouseProvider } from "@/types/lighthouse";
import { getMainFields, getProviderConfig } from "./llm-provider-registry";
import {
isProviderFormValid,
type LLMCredentialsFormData,
shouldTestConnection,
testAndRefreshModels,
} from "./llm-provider-utils";
const BEDROCK_CREDENTIAL_MODES = {
API_KEY: "api_key",
IAM: "iam",
} as const;
type BedrockCredentialMode =
(typeof BEDROCK_CREDENTIAL_MODES)[keyof typeof BEDROCK_CREDENTIAL_MODES];
const CONNECTION_STATUS = {
IDLE: "idle",
CONNECTING: "connecting",
VERIFYING: "verifying",
LOADING_MODELS: "loading-models",
} as const;
type ConnectionStatus =
(typeof CONNECTION_STATUS)[keyof typeof CONNECTION_STATUS];
const STATUS_MESSAGES: Record<Exclude<ConnectionStatus, "idle">, string> = {
[CONNECTION_STATUS.CONNECTING]: "Connecting...",
[CONNECTION_STATUS.VERIFYING]: "Verifying...",
[CONNECTION_STATUS.LOADING_MODELS]: "Loading models...",
};
interface ConnectLLMProviderProps {
provider: LighthouseProvider;
mode?: string;
initialAuthMode?: BedrockCredentialMode;
}
type FormData = Record<string, string>;
type Status = "idle" | "connecting" | "verifying" | "loading-models";
export const ConnectLLMProvider = ({
provider,
mode = "create",
initialAuthMode,
}: ConnectLLMProviderProps) => {
const router = useRouter();
const providerConfig = getProviderConfig(provider);
@@ -38,9 +64,17 @@ export const ConnectLLMProvider = ({
const [existingProviderId, setExistingProviderId] = useState<string | null>(
null,
);
const [status, setStatus] = useState<Status>("idle");
const [status, setStatus] = useState<ConnectionStatus>(
CONNECTION_STATUS.IDLE,
);
const [isFetching, setIsFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bedrockMode, setBedrockMode] = useState<BedrockCredentialMode>(() => {
if (provider === "bedrock" && mode !== "edit" && initialAuthMode) {
return initialAuthMode;
}
return BEDROCK_CREDENTIAL_MODES.API_KEY;
});
// Fetch existing provider ID in edit mode
useEffect(() => {
@@ -56,6 +90,25 @@ export const ConnectLLMProvider = ({
);
}
setExistingProviderId(result.data.id);
// For Bedrock, detect existing credential mode (API key vs IAM)
if (provider === "bedrock") {
const attributes = (result.data as any)?.attributes;
const credentials = attributes?.credentials as
| LLMCredentialsFormData
| undefined;
if (credentials) {
if (credentials.api_key) {
setBedrockMode(BEDROCK_CREDENTIAL_MODES.API_KEY);
} else if (
credentials.access_key_id ||
credentials.secret_access_key
) {
setBedrockMode(BEDROCK_CREDENTIAL_MODES.IAM);
}
}
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -66,13 +119,30 @@ export const ConnectLLMProvider = ({
fetchProvider();
}, [isEditMode, provider, providerConfig]);
const buildPayload = (): Record<string, any> => {
if (!providerConfig) return {};
const buildBedrockPayload = (): Record<string, any> => {
const credentials: LLMCredentialsFormData = {};
if (bedrockMode === BEDROCK_CREDENTIAL_MODES.API_KEY) {
if (formData.api_key) credentials.api_key = formData.api_key;
if (formData.region) credentials.region = formData.region;
} else {
if (formData.access_key_id) {
credentials.access_key_id = formData.access_key_id;
}
if (formData.secret_access_key) {
credentials.secret_access_key = formData.secret_access_key;
}
if (formData.region) credentials.region = formData.region;
}
return Object.keys(credentials).length > 0 ? { credentials } : {};
};
const buildGenericPayload = (): Record<string, any> => {
const credentials: Record<string, string> = {};
const otherFields: Record<string, string> = {};
providerConfig.fields.forEach((field) => {
providerConfig?.fields.forEach((field) => {
if (formData[field.name]) {
if (field.requiresConnectionTest) {
credentials[field.name] = formData[field.name];
@@ -88,11 +158,18 @@ export const ConnectLLMProvider = ({
};
};
const buildPayload = (): Record<string, any> => {
if (!providerConfig) return {};
return provider === "bedrock"
? buildBedrockPayload()
: buildGenericPayload();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!providerConfig) return;
setStatus("connecting");
setStatus(CONNECTION_STATUS.CONNECTING);
setError(null);
try {
@@ -125,11 +202,23 @@ export const ConnectLLMProvider = ({
setExistingProviderId(providerId);
}
const shouldTestBedrock =
(bedrockMode === BEDROCK_CREDENTIAL_MODES.API_KEY &&
!!formData.api_key?.trim()) ||
(bedrockMode === BEDROCK_CREDENTIAL_MODES.IAM &&
(!!formData.access_key_id?.trim() ||
!!formData.secret_access_key?.trim()));
const shouldTest =
provider === "bedrock"
? shouldTestBedrock
: shouldTestConnection(provider, formData);
// Test connection if credentials provided
if (shouldTestConnection(provider, formData)) {
setStatus("verifying");
if (shouldTest) {
setStatus(CONNECTION_STATUS.VERIFYING);
await testAndRefreshModels(providerId);
setStatus("loading-models");
setStatus(CONNECTION_STATUS.LOADING_MODELS);
}
// Navigate to model selection on success
@@ -138,7 +227,7 @@ export const ConnectLLMProvider = ({
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setStatus("idle");
setStatus(CONNECTION_STATUS.IDLE);
}
};
@@ -153,16 +242,44 @@ export const ConnectLLMProvider = ({
};
const getLoadingText = () => {
if (status === "idle") return "Connecting...";
const statusText: Record<Exclude<Status, "idle">, string> = {
connecting: "Connecting...",
verifying: "Verifying...",
"loading-models": "Loading models...",
};
return statusText[status] || "Connecting...";
if (status === CONNECTION_STATUS.IDLE) {
return "";
}
return (
STATUS_MESSAGES[status] || STATUS_MESSAGES[CONNECTION_STATUS.CONNECTING]
);
};
const renderFormField = (
id: string,
label: string,
type: string,
placeholder: string,
required = true,
) => (
<div>
<label htmlFor={id} className="mb-2 block text-sm font-medium">
{label}{" "}
{!isEditMode && required && <span className="text-text-error">*</span>}
{isEditMode && (
<span className="text-text-neutral-tertiary text-xs">
(leave empty to keep existing)
</span>
)}
</label>
<input
id={id}
type={type}
value={formData[id] || ""}
onChange={(e) => handleFieldChange(id, e.target.value)}
placeholder={
isEditMode ? `Enter new ${label} or leave empty` : placeholder
}
className="border-border-neutral-primary bg-bg-neutral-primary w-full rounded-lg border px-3 py-2"
/>
</div>
);
if (!providerConfig) {
return (
<div className="flex h-64 items-center justify-center">
@@ -184,8 +301,29 @@ export const ConnectLLMProvider = ({
}
const mainFields = getMainFields(provider);
const isFormValid = isProviderFormValid(provider, formData, isEditMode);
const isLoading = status !== "idle";
const isBedrockProvider = provider === "bedrock";
const isBedrockFormValid = (): boolean => {
if (isEditMode) return true;
const hasRegion = !!formData.region?.trim();
if (bedrockMode === BEDROCK_CREDENTIAL_MODES.API_KEY) {
return !!formData.api_key?.trim() && hasRegion;
}
return (
!!formData.access_key_id?.trim() &&
!!formData.secret_access_key?.trim() &&
hasRegion
);
};
const isFormValid = isBedrockProvider
? isBedrockFormValid()
: isProviderFormValid(provider, formData, isEditMode);
const isLoading = status !== CONNECTION_STATUS.IDLE;
return (
<div className="flex w-full flex-col gap-6">
@@ -209,40 +347,92 @@ export const ConnectLLMProvider = ({
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{mainFields.map((field) => (
<div key={field.name}>
<label
htmlFor={field.name}
className="mb-2 block text-sm font-medium"
>
{field.label}{" "}
{!isEditMode && field.required && (
<span className="text-text-error">*</span>
)}
{isEditMode && (
<span className="text-text-neutral-tertiary text-xs">
(leave empty to keep existing)
</span>
)}
</label>
<input
id={field.name}
type={field.type}
value={formData[field.name] || ""}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
placeholder={
isEditMode
? `Enter new ${field.label.toLowerCase()} or leave empty`
: field.placeholder
}
className="border-border-neutral-primary bg-bg-neutral-primary w-full rounded-lg border px-3 py-2"
/>
</div>
))}
{isBedrockProvider ? (
<>
{bedrockMode === BEDROCK_CREDENTIAL_MODES.API_KEY && (
<div className="border-border-warning-primary bg-bg-warning-secondary rounded-lg border p-4">
<p className="text-text-warning text-sm font-medium">
Recommended only for exploration of Amazon Bedrock.
</p>
<p className="text-text-warning mt-1 text-xs">
Please ensure you&apos;re using long-term Bedrock API keys.
</p>
</div>
)}
{bedrockMode === BEDROCK_CREDENTIAL_MODES.API_KEY ? (
<>
{renderFormField(
"api_key",
"API key (long-term)",
"password",
"Enter your long-term API key",
)}
{renderFormField(
"region",
"AWS region",
"text",
"Enter the AWS region",
)}
</>
) : (
<>
{renderFormField(
"access_key_id",
"AWS access key ID",
"password",
"Enter the AWS Access Key ID",
)}
{renderFormField(
"secret_access_key",
"AWS secret access key",
"password",
"Enter the AWS Secret Access Key",
)}
{renderFormField(
"region",
"AWS region",
"text",
"Enter the AWS Region",
)}
</>
)}
</>
) : (
mainFields.map((field) => (
<div key={field.name}>
<label
htmlFor={field.name}
className="mb-2 block text-sm font-medium"
>
{field.label}{" "}
{!isEditMode && field.required && (
<span className="text-text-error">*</span>
)}
{isEditMode && (
<span className="text-text-neutral-tertiary text-xs">
(leave empty to keep existing)
</span>
)}
</label>
<input
id={field.name}
type={field.type}
value={formData[field.name] || ""}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
placeholder={
isEditMode
? `Enter new ${field.label} or leave empty`
: field.placeholder
}
className="border-border-neutral-primary bg-bg-neutral-primary w-full rounded-lg border px-3 py-2"
/>
</div>
))
)}
<FormButtons
onCancel={() => router.push("/lighthouse/config")}
submitText={getSubmitText()}
submitText={isLoading ? getLoadingText() : getSubmitText()}
loadingText={getLoadingText()}
isDisabled={!isFormValid || isLoading}
/>

View File

@@ -0,0 +1,59 @@
"use client";
import { RadioGroup } from "@heroui/radio";
import { useRouter, useSearchParams } from "next/navigation";
import { CustomRadio } from "@/components/ui/custom";
const BEDROCK_AUTH_METHODS = {
API_KEY: "api_key",
IAM: "iam",
} as const;
type BedrockAuthMethod =
(typeof BEDROCK_AUTH_METHODS)[keyof typeof BEDROCK_AUTH_METHODS];
export const SelectBedrockAuthMethod = () => {
const router = useRouter();
const searchParams = useSearchParams();
const currentAuth = searchParams.get("auth") as BedrockAuthMethod | null;
const handleSelectionChange = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("auth", value);
router.push(`?${params.toString()}`);
};
return (
<div className="flex w-full flex-col gap-6">
<div>
<h2 className="mb-2 text-xl font-semibold">Connect Amazon Bedrock</h2>
<p className="text-text-neutral-secondary text-sm">
Choose how you want to authenticate with Amazon Bedrock. You can use a
dedicated Bedrock API key or long-term AWS access keys.
</p>
</div>
<div className="flex flex-col gap-3">
<RadioGroup
className="flex flex-col gap-3"
value={currentAuth || ""}
onValueChange={handleSelectionChange}
>
<CustomRadio value={BEDROCK_AUTH_METHODS.API_KEY}>
<div className="flex items-center">
<span className="ml-2">Use Bedrock API Key</span>
</div>
</CustomRadio>
<CustomRadio value={BEDROCK_AUTH_METHODS.IAM}>
<div className="flex items-center">
<span className="ml-2">Use AWS Access Key & Secret</span>
</div>
</CustomRadio>
</RadioGroup>
</div>
</div>
);
};