fix: complete bedrock support and UI/UX improvements

- Add bedrock to ALLOWED_CLIENT_PROVIDERS for client credentials
- Pass AWS credentials through full chain (headers → API → provider)
- Replace non-existent GPT-5 models with real ones (o1, o3-mini)
- Add accessibility: aria-labels, focus-visible rings, inline errors
- Add more AWS regions (Ohio, London, Paris, Mumbai, Seoul, São Paulo)
- Fix setTimeout cleanup with useRef on component unmount
- Fix TypeScript type consistency in getSelectedAIConfig fallback
This commit is contained in:
dayuan.jiang
2025-12-22 20:40:12 +09:00
parent 1be0cfa06c
commit 7ed7b29274
6 changed files with 223 additions and 113 deletions

View File

@@ -214,6 +214,11 @@ async function handleChatRequest(req: Request): Promise<Response> {
baseUrl: req.headers.get("x-ai-base-url"), baseUrl: req.headers.get("x-ai-base-url"),
apiKey: req.headers.get("x-ai-api-key"), apiKey: req.headers.get("x-ai-api-key"),
modelId: req.headers.get("x-ai-model"), 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 // Read minimal style preference from header

View File

@@ -1041,6 +1041,20 @@ Continue from EXACTLY where you stopped.`,
"x-ai-api-key": config.aiApiKey, "x-ai-api-key": config.aiApiKey,
}), }),
...(config.aiModel && { "x-ai-model": config.aiModel }), ...(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 && { ...(minimalStyle && {
"x-minimal-style": "true", "x-minimal-style": "true",

View File

@@ -116,6 +116,9 @@ export function ModelConfigDialog({
const [scrollState, setScrollState] = useState({ top: false, bottom: true }) const [scrollState, setScrollState] = useState({ top: false, bottom: true })
const [customModelInput, setCustomModelInput] = useState("") const [customModelInput, setCustomModelInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const validationResetTimeoutRef = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [validatingModelIndex, setValidatingModelIndex] = useState< const [validatingModelIndex, setValidatingModelIndex] = useState<
number | null number | null
@@ -156,6 +159,15 @@ export function ModelConfigDialog({
return () => scrollEl.removeEventListener("scroll", handleScroll) return () => scrollEl.removeEventListener("scroll", handleScroll)
}, [selectedProvider]) }, [selectedProvider])
// Cleanup validation reset timeout on unmount
useEffect(() => {
return () => {
if (validationResetTimeoutRef.current) {
clearTimeout(validationResetTimeoutRef.current)
}
}
}, [])
// Get suggested models for current provider // Get suggested models for current provider
const suggestedModels = selectedProvider const suggestedModels = selectedProvider
? SUGGESTED_MODELS[selectedProvider.provider] || [] ? SUGGESTED_MODELS[selectedProvider.provider] || []
@@ -292,8 +304,14 @@ export function ModelConfigDialog({
if (allValid) { if (allValid) {
setValidationStatus("success") setValidationStatus("success")
updateProvider(selectedProviderId!, { validated: true }) updateProvider(selectedProviderId!, { validated: true })
// Reset to idle after showing success briefly // Reset to idle after showing success briefly (with cleanup)
setTimeout(() => setValidationStatus("idle"), 1500) if (validationResetTimeoutRef.current) {
clearTimeout(validationResetTimeoutRef.current)
}
validationResetTimeoutRef.current = setTimeout(() => {
setValidationStatus("idle")
validationResetTimeoutRef.current = null
}, 1500)
} else { } else {
setValidationStatus("error") setValidationStatus("error")
setValidationError(`${errorCount} model(s) failed validation`) setValidationError(`${errorCount} model(s) failed validation`)
@@ -360,7 +378,7 @@ export function ModelConfigDialog({
setShowApiKey(false) setShowApiKey(false)
}} }}
className={cn( 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 === selectedProviderId ===
provider.id && provider.id &&
"bg-background shadow-sm ring-1 ring-border", "bg-background shadow-sm ring-1 ring-border",
@@ -605,7 +623,12 @@ export function ModelConfigDialog({
!showApiKey, !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 ? ( {showApiKey ? (
<EyeOff className="h-4 w-4" /> <EyeOff className="h-4 w-4" />
@@ -642,12 +665,16 @@ export function ModelConfigDialog({
<SelectTrigger className="h-9 font-mono text-xs hover:bg-accent"> <SelectTrigger className="h-9 font-mono text-xs hover:bg-accent">
<SelectValue placeholder="Select region" /> <SelectValue placeholder="Select region" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="max-h-64">
<SelectItem value="us-east-1"> <SelectItem value="us-east-1">
us-east-1 us-east-1
(N. (N.
Virginia) Virginia)
</SelectItem> </SelectItem>
<SelectItem value="us-east-2">
us-east-2
(Ohio)
</SelectItem>
<SelectItem value="us-west-2"> <SelectItem value="us-west-2">
us-west-2 us-west-2
(Oregon) (Oregon)
@@ -656,14 +683,30 @@ export function ModelConfigDialog({
eu-west-1 eu-west-1
(Ireland) (Ireland)
</SelectItem> </SelectItem>
<SelectItem value="eu-west-2">
eu-west-2
(London)
</SelectItem>
<SelectItem value="eu-west-3">
eu-west-3
(Paris)
</SelectItem>
<SelectItem value="eu-central-1"> <SelectItem value="eu-central-1">
eu-central-1 eu-central-1
(Frankfurt) (Frankfurt)
</SelectItem> </SelectItem>
<SelectItem value="ap-south-1">
ap-south-1
(Mumbai)
</SelectItem>
<SelectItem value="ap-northeast-1"> <SelectItem value="ap-northeast-1">
ap-northeast-1 ap-northeast-1
(Tokyo) (Tokyo)
</SelectItem> </SelectItem>
<SelectItem value="ap-northeast-2">
ap-northeast-2
(Seoul)
</SelectItem>
<SelectItem value="ap-southeast-1"> <SelectItem value="ap-southeast-1">
ap-southeast-1 ap-southeast-1
(Singapore) (Singapore)
@@ -672,6 +715,11 @@ export function ModelConfigDialog({
ap-southeast-2 ap-southeast-2
(Sydney) (Sydney)
</SelectItem> </SelectItem>
<SelectItem value="sa-east-1">
sa-east-1
(São
Paulo)
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -771,7 +819,12 @@ export function ModelConfigDialog({
!showApiKey, !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 ? ( {showApiKey ? (
<EyeOff className="h-4 w-4" /> <EyeOff className="h-4 w-4" />
@@ -984,7 +1037,7 @@ export function ModelConfigDialog({
model.id model.id
} }
className={cn( className={cn(
"flex items-center gap-3 p-3 transition-colors hover:bg-muted/30", "transition-colors hover:bg-muted/30",
index === index ===
0 && 0 &&
"rounded-t-xl", "rounded-t-xl",
@@ -996,87 +1049,94 @@ export function ModelConfigDialog({
"rounded-b-xl", "rounded-b-xl",
)} )}
> >
{/* Status icon */} <div className="flex items-center gap-3 p-3">
<div {/* Status icon */}
className="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0" <div className="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0">
title={ {validatingModelIndex !==
model.validationError || null &&
"" index ===
} validatingModelIndex ? (
> // Currently validating
{validatingModelIndex !== <div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center">
null && <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
index === </div>
validatingModelIndex ? ( ) : validatingModelIndex !==
// Currently validating null &&
<div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center"> index >
<Loader2 className="h-4 w-4 text-blue-500 animate-spin" /> validatingModelIndex &&
</div> model.validated ===
) : validatingModelIndex !== undefined ? (
null && // Queued
index > <div className="w-full h-full rounded-lg bg-muted flex items-center justify-center">
validatingModelIndex && <Clock className="h-4 w-4 text-muted-foreground" />
model.validated === </div>
undefined ? ( ) : model.validated ===
// Queued true ? (
<div className="w-full h-full rounded-lg bg-muted flex items-center justify-center"> // Valid
<Clock className="h-4 w-4 text-muted-foreground" /> <div className="w-full h-full rounded-lg bg-emerald-500/10 flex items-center justify-center">
</div> <Check className="h-4 w-4 text-emerald-500" />
) : model.validated === </div>
true ? ( ) : model.validated ===
// Valid false ? (
<div className="w-full h-full rounded-lg bg-emerald-500/10 flex items-center justify-center"> // Invalid
<Check className="h-4 w-4 text-emerald-500" /> <div className="w-full h-full rounded-lg bg-destructive/10 flex items-center justify-center">
</div> <AlertCircle className="h-4 w-4 text-destructive" />
) : model.validated === </div>
false ? ( ) : (
// Invalid // Not validated yet
<div className="w-full h-full rounded-lg bg-destructive/10 flex items-center justify-center"> <div className="w-full h-full rounded-lg bg-primary/5 flex items-center justify-center">
<AlertCircle className="h-4 w-4 text-destructive" /> <Zap className="h-4 w-4 text-primary" />
</div> </div>
) : ( )}
// Not validated yet </div>
<div className="w-full h-full rounded-lg bg-primary/5 flex items-center justify-center"> <Input
<Zap className="h-4 w-4 text-primary" /> value={
</div> model.modelId
)} }
onChange={(
e,
) => {
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"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() =>
handleDeleteModel(
model.id,
)
}
aria-label={`Delete ${model.modelId}`}
>
<X className="h-4 w-4" />
</Button>
</div> </div>
<Input {/* Show validation error inline */}
value={ {model.validated ===
model.modelId false &&
} model.validationError && (
onChange={( <p className="text-xs text-destructive px-3 pb-2 pl-14">
e,
) => {
updateModel(
selectedProviderId!,
model.id,
{ {
modelId: model.validationError
e }
.target </p>
.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"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() =>
handleDeleteModel(
model.id,
)
}
>
<X className="h-4 w-4" />
</Button>
</div> </div>
), ),
)} )}

View File

@@ -299,6 +299,11 @@ export function getSelectedAIConfig(): {
aiBaseUrl: string aiBaseUrl: string
aiApiKey: string aiApiKey: string
aiModel: string aiModel: string
// AWS Bedrock credentials
awsAccessKeyId: string
awsSecretAccessKey: string
awsRegion: string
awsSessionToken: string
} { } {
const empty = { const empty = {
accessCode: "", accessCode: "",
@@ -306,6 +311,10 @@ export function getSelectedAIConfig(): {
aiBaseUrl: "", aiBaseUrl: "",
aiApiKey: "", aiApiKey: "",
aiModel: "", aiModel: "",
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegion: "",
awsSessionToken: "",
} }
if (typeof window === "undefined") return empty if (typeof window === "undefined") return empty
@@ -323,6 +332,11 @@ export function getSelectedAIConfig(): {
aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "", aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "",
aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "", aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "",
aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "", 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 || "", aiBaseUrl: model.baseUrl || "",
aiApiKey: model.apiKey, aiApiKey: model.apiKey,
aiModel: model.modelId, aiModel: model.modelId,
// AWS Bedrock credentials
awsAccessKeyId: model.awsAccessKeyId || "",
awsSecretAccessKey: model.awsSecretAccessKey || "",
awsRegion: model.awsRegion || "",
awsSessionToken: model.awsSessionToken || "",
} }
} }

View File

@@ -33,6 +33,11 @@ export interface ClientOverrides {
baseUrl?: string | null baseUrl?: string | null
apiKey?: string | null apiKey?: string | null
modelId?: 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 // Providers that can be used with client-provided API keys
@@ -41,6 +46,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"anthropic", "anthropic",
"google", "google",
"azure", "azure",
"bedrock",
"openrouter", "openrouter",
"deepseek", "deepseek",
"siliconflow", "siliconflow",
@@ -537,12 +543,25 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
switch (provider) { switch (provider) {
case "bedrock": { case "bedrock": {
// Use credential provider chain for IAM role support (Lambda, EC2, etc.) // Use client-provided credentials if available, otherwise fall back to IAM/env vars
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev const hasClientCredentials =
const bedrockProvider = createAmazonBedrock({ overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
region: process.env.AWS_REGION || "us-west-2", const bedrockRegion =
credentialProvider: fromNodeProviderChain(), 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) model = bedrockProvider(modelId)
// Add Anthropic beta options if using Claude models via Bedrock // Add Anthropic beta options if using Claude models via Bedrock
if (modelId.includes("anthropic.claude")) { if (modelId.includes("anthropic.claude")) {

View File

@@ -83,29 +83,22 @@ export const PROVIDER_INFO: Record<
// Suggested models per provider for quick add // Suggested models per provider for quick add
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = { export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
openai: [ openai: [
// GPT-5.2 series // GPT-4o series (latest)
"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", "gpt-4o",
"gpt-4o-mini", "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: [ anthropic: [
// Claude 4.5 series (latest) // Claude 4.5 series (latest)