diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 5c44683..b9024e4 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -214,6 +214,11 @@ async function handleChatRequest(req: Request): Promise { baseUrl: req.headers.get("x-ai-base-url"), apiKey: req.headers.get("x-ai-api-key"), modelId: req.headers.get("x-ai-model"), + // AWS Bedrock credentials + awsAccessKeyId: req.headers.get("x-aws-access-key-id"), + awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"), + awsRegion: req.headers.get("x-aws-region"), + awsSessionToken: req.headers.get("x-aws-session-token"), } // Read minimal style preference from header diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index 14bc2b4..d0f32d1 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -1041,6 +1041,20 @@ Continue from EXACTLY where you stopped.`, "x-ai-api-key": config.aiApiKey, }), ...(config.aiModel && { "x-ai-model": config.aiModel }), + // AWS Bedrock credentials + ...(config.awsAccessKeyId && { + "x-aws-access-key-id": config.awsAccessKeyId, + }), + ...(config.awsSecretAccessKey && { + "x-aws-secret-access-key": + config.awsSecretAccessKey, + }), + ...(config.awsRegion && { + "x-aws-region": config.awsRegion, + }), + ...(config.awsSessionToken && { + "x-aws-session-token": config.awsSessionToken, + }), }), ...(minimalStyle && { "x-minimal-style": "true", diff --git a/components/model-config-dialog.tsx b/components/model-config-dialog.tsx index 899440a..1880e3f 100644 --- a/components/model-config-dialog.tsx +++ b/components/model-config-dialog.tsx @@ -116,6 +116,9 @@ export function ModelConfigDialog({ const [scrollState, setScrollState] = useState({ top: false, bottom: true }) const [customModelInput, setCustomModelInput] = useState("") const scrollRef = useRef(null) + const validationResetTimeoutRef = useRef | null>(null) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [validatingModelIndex, setValidatingModelIndex] = useState< number | null @@ -156,6 +159,15 @@ export function ModelConfigDialog({ return () => scrollEl.removeEventListener("scroll", handleScroll) }, [selectedProvider]) + // Cleanup validation reset timeout on unmount + useEffect(() => { + return () => { + if (validationResetTimeoutRef.current) { + clearTimeout(validationResetTimeoutRef.current) + } + } + }, []) + // Get suggested models for current provider const suggestedModels = selectedProvider ? SUGGESTED_MODELS[selectedProvider.provider] || [] @@ -292,8 +304,14 @@ export function ModelConfigDialog({ if (allValid) { setValidationStatus("success") updateProvider(selectedProviderId!, { validated: true }) - // Reset to idle after showing success briefly - setTimeout(() => setValidationStatus("idle"), 1500) + // Reset to idle after showing success briefly (with cleanup) + if (validationResetTimeoutRef.current) { + clearTimeout(validationResetTimeoutRef.current) + } + validationResetTimeoutRef.current = setTimeout(() => { + setValidationStatus("idle") + validationResetTimeoutRef.current = null + }, 1500) } else { setValidationStatus("error") setValidationError(`${errorCount} model(s) failed validation`) @@ -360,7 +378,7 @@ export function ModelConfigDialog({ setShowApiKey(false) }} className={cn( - "group flex items-center gap-3 px-3 py-2.5 rounded-lg text-left text-sm transition-all duration-150 hover:bg-accent", + "group flex items-center gap-3 px-3 py-2.5 rounded-lg text-left text-sm transition-all duration-150 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", selectedProviderId === provider.id && "bg-background shadow-sm ring-1 ring-border", @@ -605,7 +623,12 @@ export function ModelConfigDialog({ !showApiKey, ) } - className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors" + aria-label={ + showApiKey + ? "Hide secret access key" + : "Show secret access key" + } + className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded" > {showApiKey ? ( @@ -642,12 +665,16 @@ export function ModelConfigDialog({ - + us-east-1 (N. Virginia) + + us-east-2 + (Ohio) + us-west-2 (Oregon) @@ -656,14 +683,30 @@ export function ModelConfigDialog({ eu-west-1 (Ireland) + + eu-west-2 + (London) + + + eu-west-3 + (Paris) + eu-central-1 (Frankfurt) + + ap-south-1 + (Mumbai) + ap-northeast-1 (Tokyo) + + ap-northeast-2 + (Seoul) + ap-southeast-1 (Singapore) @@ -672,6 +715,11 @@ export function ModelConfigDialog({ ap-southeast-2 (Sydney) + + sa-east-1 + (São + Paulo) + @@ -771,7 +819,12 @@ export function ModelConfigDialog({ !showApiKey, ) } - className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors" + aria-label={ + showApiKey + ? "Hide API key" + : "Show API key" + } + className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded" > {showApiKey ? ( @@ -984,7 +1037,7 @@ export function ModelConfigDialog({ model.id } className={cn( - "flex items-center gap-3 p-3 transition-colors hover:bg-muted/30", + "transition-colors hover:bg-muted/30", index === 0 && "rounded-t-xl", @@ -996,87 +1049,94 @@ export function ModelConfigDialog({ "rounded-b-xl", )} > - {/* Status icon */} -
- {validatingModelIndex !== - null && - index === - validatingModelIndex ? ( - // Currently validating -
- -
- ) : validatingModelIndex !== - null && - index > - validatingModelIndex && - model.validated === - undefined ? ( - // Queued -
- -
- ) : model.validated === - true ? ( - // Valid -
- -
- ) : model.validated === - false ? ( - // Invalid -
- -
- ) : ( - // Not validated yet -
- -
- )} +
+ {/* Status icon */} +
+ {validatingModelIndex !== + null && + index === + validatingModelIndex ? ( + // Currently validating +
+ +
+ ) : validatingModelIndex !== + null && + index > + validatingModelIndex && + model.validated === + undefined ? ( + // Queued +
+ +
+ ) : model.validated === + true ? ( + // Valid +
+ +
+ ) : model.validated === + false ? ( + // Invalid +
+ +
+ ) : ( + // Not validated yet +
+ +
+ )} +
+ { + updateModel( + selectedProviderId!, + model.id, + { + modelId: + e + .target + .value, + validated: + undefined, + validationError: + undefined, + }, + ) + }} + className="flex-1 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1" + /> +
- { - updateModel( - selectedProviderId!, - model.id, + {/* Show validation error inline */} + {model.validated === + false && + model.validationError && ( +

{ - modelId: - e - .target - .value, - validated: - undefined, - validationError: - undefined, - }, - ) - }} - className="flex-1 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1" - /> - + model.validationError + } +

+ )}
), )} diff --git a/hooks/use-model-config.ts b/hooks/use-model-config.ts index a0a07c2..08c89c4 100644 --- a/hooks/use-model-config.ts +++ b/hooks/use-model-config.ts @@ -299,6 +299,11 @@ export function getSelectedAIConfig(): { aiBaseUrl: string aiApiKey: string aiModel: string + // AWS Bedrock credentials + awsAccessKeyId: string + awsSecretAccessKey: string + awsRegion: string + awsSessionToken: string } { const empty = { accessCode: "", @@ -306,6 +311,10 @@ export function getSelectedAIConfig(): { aiBaseUrl: "", aiApiKey: "", aiModel: "", + awsAccessKeyId: "", + awsSecretAccessKey: "", + awsRegion: "", + awsSessionToken: "", } if (typeof window === "undefined") return empty @@ -323,6 +332,11 @@ export function getSelectedAIConfig(): { aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "", aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "", aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "", + // Old format didn't support AWS + awsAccessKeyId: "", + awsSecretAccessKey: "", + awsRegion: "", + awsSessionToken: "", } } @@ -350,5 +364,10 @@ export function getSelectedAIConfig(): { aiBaseUrl: model.baseUrl || "", aiApiKey: model.apiKey, aiModel: model.modelId, + // AWS Bedrock credentials + awsAccessKeyId: model.awsAccessKeyId || "", + awsSecretAccessKey: model.awsSecretAccessKey || "", + awsRegion: model.awsRegion || "", + awsSessionToken: model.awsSessionToken || "", } } diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index 101ea70..02ecb1e 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -33,6 +33,11 @@ export interface ClientOverrides { baseUrl?: string | null apiKey?: string | null modelId?: string | null + // AWS Bedrock credentials + awsAccessKeyId?: string | null + awsSecretAccessKey?: string | null + awsRegion?: string | null + awsSessionToken?: string | null } // Providers that can be used with client-provided API keys @@ -41,6 +46,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [ "anthropic", "google", "azure", + "bedrock", "openrouter", "deepseek", "siliconflow", @@ -537,12 +543,25 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { switch (provider) { case "bedrock": { - // Use credential provider chain for IAM role support (Lambda, EC2, etc.) - // Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev - const bedrockProvider = createAmazonBedrock({ - region: process.env.AWS_REGION || "us-west-2", - credentialProvider: fromNodeProviderChain(), - }) + // Use client-provided credentials if available, otherwise fall back to IAM/env vars + const hasClientCredentials = + overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey + const bedrockRegion = + overrides?.awsRegion || process.env.AWS_REGION || "us-west-2" + + const bedrockProvider = hasClientCredentials + ? createAmazonBedrock({ + region: bedrockRegion, + accessKeyId: overrides.awsAccessKeyId!, + secretAccessKey: overrides.awsSecretAccessKey!, + ...(overrides?.awsSessionToken && { + sessionToken: overrides.awsSessionToken, + }), + }) + : createAmazonBedrock({ + region: bedrockRegion, + credentialProvider: fromNodeProviderChain(), + }) model = bedrockProvider(modelId) // Add Anthropic beta options if using Claude models via Bedrock if (modelId.includes("anthropic.claude")) { diff --git a/lib/types/model-config.ts b/lib/types/model-config.ts index fd17250..150fd1f 100644 --- a/lib/types/model-config.ts +++ b/lib/types/model-config.ts @@ -83,29 +83,22 @@ export const PROVIDER_INFO: Record< // Suggested models per provider for quick add export const SUGGESTED_MODELS: Record = { openai: [ - // GPT-5.2 series - "gpt-5.2-pro", - "gpt-5.2-chat-latest", - "gpt-5.2", - // GPT-5.1 series - "gpt-5.1-codex-mini", - "gpt-5.1-codex", - "gpt-5.1-chat-latest", - "gpt-5.1", - // GPT-5 series - "gpt-5-pro", - "gpt-5", - "gpt-5-mini", - "gpt-5-nano", - "gpt-5-codex", - "gpt-5-chat-latest", - // GPT-4.1 series - "gpt-4.1", - "gpt-4.1-mini", - "gpt-4.1-nano", - // GPT-4o series + // GPT-4o series (latest) "gpt-4o", "gpt-4o-mini", + "gpt-4o-2024-11-20", + // GPT-4 Turbo + "gpt-4-turbo", + "gpt-4-turbo-preview", + // o1/o3 reasoning models + "o1", + "o1-mini", + "o1-preview", + "o3-mini", + // GPT-4 + "gpt-4", + // GPT-3.5 + "gpt-3.5-turbo", ], anthropic: [ // Claude 4.5 series (latest)