fix: implement backend support for custom AWS regions

- Backend now properly uses awsCustomRegion when awsRegion is 'custom'
- Added i18n translations for custom region UI elements
- Added validation for AWS region format (e.g., us-west-3)
- Improved state management to preserve custom region value when switching
- Added comprehensive tests for custom region functionality
This commit is contained in:
Roo Code 2025-07-22 17:54:50 +00:00
parent 24d2adb298
commit 9008f16cca
5 changed files with 380 additions and 13 deletions

View file

@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { AwsBedrockHandler } from "../bedrock"
import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime"
// Mock the AWS SDK
vi.mock("@aws-sdk/client-bedrock-runtime", () => ({
BedrockRuntimeClient: vi.fn().mockImplementation((config) => ({
config,
send: vi.fn(),
})),
ConverseCommand: vi.fn(),
ConverseStreamCommand: vi.fn(),
}))
describe("AwsBedrockHandler - Custom Region Support", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("should use custom region when awsRegion is 'custom' and awsCustomRegion is provided", () => {
const handler = new AwsBedrockHandler({
apiProvider: "bedrock",
apiModelId: "anthropic.claude-3-sonnet-20240229-v1:0",
awsAccessKey: "test-access-key",
awsSecretKey: "test-secret-key",
awsRegion: "custom",
awsCustomRegion: "us-west-3",
})
// Get the mock instance to check the config
const mockClientInstance = vi.mocked(BedrockRuntimeClient).mock.results[0]?.value
expect(mockClientInstance.config.region).toBe("us-west-3")
})
it("should use standard region when awsRegion is not 'custom'", () => {
const handler = new AwsBedrockHandler({
apiProvider: "bedrock",
apiModelId: "anthropic.claude-3-sonnet-20240229-v1:0",
awsAccessKey: "test-access-key",
awsSecretKey: "test-secret-key",
awsRegion: "us-east-1",
awsCustomRegion: "us-west-3", // This should be ignored
})
// Get the mock instance to check the config
const mockClientInstance = vi.mocked(BedrockRuntimeClient).mock.results[0]?.value
expect(mockClientInstance.config.region).toBe("us-east-1")
})
it("should use awsRegion when awsCustomRegion is not provided", () => {
const handler = new AwsBedrockHandler({
apiProvider: "bedrock",
apiModelId: "anthropic.claude-3-sonnet-20240229-v1:0",
awsAccessKey: "test-access-key",
awsSecretKey: "test-secret-key",
awsRegion: "custom",
// awsCustomRegion is not provided
})
// Get the mock instance to check the config
const mockClientInstance = vi.mocked(BedrockRuntimeClient).mock.results[0]?.value
expect(mockClientInstance.config.region).toBe("custom")
})
it("should use custom region for cross-region inference prefix calculation", () => {
const handler = new AwsBedrockHandler({
apiProvider: "bedrock",
apiModelId: "anthropic.claude-3-sonnet-20240229-v1:0",
awsAccessKey: "test-access-key",
awsSecretKey: "test-secret-key",
awsRegion: "custom",
awsCustomRegion: "us-west-3",
awsUseCrossRegionInference: true,
})
const model = handler.getModel()
// Should have the us. prefix for us-west-3
expect(model.id).toContain("us.")
})
it("should handle custom regions with different prefixes for cross-region inference", () => {
const testCases = [
{ customRegion: "eu-central-3", expectedPrefix: "eu." },
{ customRegion: "ap-southeast-4", expectedPrefix: "apac." },
{ customRegion: "ca-west-1", expectedPrefix: "ca." },
{ customRegion: "sa-east-2", expectedPrefix: "sa." },
{ customRegion: "us-gov-west-2", expectedPrefix: "ug." },
]
for (const { customRegion, expectedPrefix } of testCases) {
vi.clearAllMocks()
const handler = new AwsBedrockHandler({
apiProvider: "bedrock",
apiModelId: "anthropic.claude-3-sonnet-20240229-v1:0",
awsAccessKey: "test-access-key",
awsSecretKey: "test-secret-key",
awsRegion: "custom",
awsCustomRegion: customRegion,
awsUseCrossRegionInference: true,
})
const model = handler.getModel()
expect(model.id).toContain(expectedPrefix)
}
})
})

View file

@ -169,7 +169,11 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
constructor(options: ProviderSettings) {
super()
this.options = options
let region = this.options.awsRegion
// Use custom region if awsRegion is "custom"
let region =
this.options.awsRegion === "custom" && this.options.awsCustomRegion
? this.options.awsCustomRegion
: this.options.awsRegion
// process the various user input options, be opinionated about the intent of the options
// and determine the model to use during inference and for cost calculations
@ -216,7 +220,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
this.costModelConfig = this.getModel()
const clientConfig: BedrockRuntimeClientConfig = {
region: this.options.awsRegion,
region: region, // Use the resolved region (either standard or custom)
// Add the endpoint configuration when specified and enabled
...(this.options.awsBedrockEndpoint &&
this.options.awsBedrockEndpointEnabled && { endpoint: this.options.awsBedrockEndpoint }),
@ -943,10 +947,18 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
modelConfig = this.getModelById(this.options.apiModelId as string)
// Add cross-region inference prefix if enabled
if (this.options.awsUseCrossRegionInference && this.options.awsRegion) {
const prefix = AwsBedrockHandler.getPrefixForRegion(this.options.awsRegion)
if (prefix) {
modelConfig.id = `${prefix}${modelConfig.id}`
if (this.options.awsUseCrossRegionInference) {
// Use custom region if awsRegion is "custom"
const regionToUse =
this.options.awsRegion === "custom" && this.options.awsCustomRegion
? this.options.awsCustomRegion
: this.options.awsRegion
if (regionToUse) {
const prefix = AwsBedrockHandler.getPrefixForRegion(regionToUse)
if (prefix) {
modelConfig.id = `${prefix}${modelConfig.id}`
}
}
}
}

View file

@ -9,6 +9,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Standard
import { inputEventTransform, noTransform } from "../transforms"
// AWS region format validation regex
const AWS_REGION_REGEX = /^[a-z]{2,}-[a-z]+-\d+$/
type BedrockProps = {
apiConfiguration: ProviderSettings
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
@ -19,6 +22,7 @@ export const Bedrock = ({ apiConfiguration, setApiConfigurationField, selectedMo
const { t } = useAppTranslation()
const [awsEndpointSelected, setAwsEndpointSelected] = useState(!!apiConfiguration?.awsBedrockEndpointEnabled)
const [customRegionSelected, setCustomRegionSelected] = useState(apiConfiguration?.awsRegion === "custom")
const [customRegionError, setCustomRegionError] = useState<string | null>(null)
// Update the endpoint enabled state when the configuration changes
useEffect(() => {
@ -41,6 +45,34 @@ export const Bedrock = ({ apiConfiguration, setApiConfigurationField, selectedMo
[setApiConfigurationField],
)
// Validate custom region format
const validateCustomRegion = useCallback(
(value: string) => {
if (!value && customRegionSelected) {
setCustomRegionError(t("settings:providers.awsCustomRegion.validation.required"))
return false
}
if (value && !AWS_REGION_REGEX.test(value)) {
setCustomRegionError(t("settings:providers.awsCustomRegion.validation.format"))
return false
}
setCustomRegionError(null)
return true
},
[customRegionSelected, t],
)
// Handle custom region input change with validation
const handleCustomRegionChange = useCallback(
(event: Event | React.FormEvent<HTMLElement>) => {
const target = event.target as HTMLInputElement
const value = target.value
validateCustomRegion(value)
setApiConfigurationField("awsCustomRegion", value)
},
[setApiConfigurationField, validateCustomRegion],
)
return (
<>
<VSCodeRadioGroup
@ -98,9 +130,13 @@ export const Bedrock = ({ apiConfiguration, setApiConfigurationField, selectedMo
onValueChange={(value) => {
setApiConfigurationField("awsRegion", value)
setCustomRegionSelected(value === "custom")
// Clear custom region when switching to a standard region
if (value !== "custom") {
setApiConfigurationField("awsCustomRegion", "")
// Don't clear custom region when switching away - preserve the value
if (value === "custom" && apiConfiguration?.awsCustomRegion) {
// Validate the existing custom region value
validateCustomRegion(apiConfiguration.awsCustomRegion)
} else {
// Clear validation error when not using custom region
setCustomRegionError(null)
}
}}>
<SelectTrigger className="w-full">
@ -120,10 +156,14 @@ export const Bedrock = ({ apiConfiguration, setApiConfigurationField, selectedMo
<VSCodeTextField
value={apiConfiguration?.awsCustomRegion || ""}
style={{ width: "100%", marginTop: 3, marginBottom: 5 }}
onInput={handleInputChange("awsCustomRegion")}
placeholder={t("settings:placeholders.customRegion")}
onInput={handleCustomRegionChange}
placeholder={t("settings:providers.awsCustomRegion.placeholder")}
data-testid="custom-region-input"
className={customRegionError ? "error" : ""}
/>
{customRegionError && (
<div className="text-sm text-vscode-errorForeground ml-6 mt-1 mb-2">{customRegionError}</div>
)}
<div className="text-sm text-vscode-descriptionForeground ml-6 mt-1 mb-3">
{t("settings:providers.awsCustomRegion.examples")}
<div className="ml-2"> us-west-3</div>

View file

@ -61,11 +61,18 @@ vi.mock("@src/i18n/TranslationContext", () => ({
// Mock the UI components
vi.mock("@src/components/ui", () => ({
Select: ({ children }: any) => <div>{children}</div>,
Select: ({ children, onValueChange }: any) => {
// Store the onValueChange callback on the window for testing
if (typeof window !== "undefined") {
;(window as any).__selectOnValueChange = onValueChange
}
return <div data-testid="select-component">{children}</div>
},
SelectContent: ({ children }: any) => <div>{children}</div>,
SelectItem: () => <div>Item</div>,
SelectTrigger: ({ children }: any) => <div>{children}</div>,
SelectTrigger: ({ children }: any) => <div role="combobox">{children}</div>,
SelectValue: () => <div>Value</div>,
StandardTooltip: ({ children }: any) => <div>{children}</div>,
}))
// Mock the constants
@ -636,4 +643,196 @@ describe("Bedrock Component", () => {
expect(screen.getByText("settings:providers.awsCustomRegion.examples")).toBeInTheDocument()
})
})
// Test Scenario 7: Custom Region Validation Tests
describe("Custom Region Validation", () => {
it("should show validation error when custom region is empty", () => {
const apiConfiguration: Partial<ProviderSettings> = {
awsRegion: "custom",
awsCustomRegion: "",
awsUseProfile: true,
}
render(
<Bedrock
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
/>,
)
// The custom region input should be visible
const customRegionInput = screen.getByTestId("custom-region-input")
expect(customRegionInput).toBeInTheDocument()
// Trigger validation by changing input
fireEvent.change(customRegionInput, { target: { value: "" } })
// Should show required validation error
expect(screen.getByText("settings:providers.awsCustomRegion.validation.required")).toBeInTheDocument()
})
it("should show validation error for invalid region format", () => {
const apiConfiguration: Partial<ProviderSettings> = {
awsRegion: "custom",
awsCustomRegion: "",
awsUseProfile: true,
}
render(
<Bedrock
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
/>,
)
const customRegionInput = screen.getByTestId("custom-region-input")
// Test various invalid formats
const invalidRegions = [
"invalid-region",
"us-west",
"us-3",
"uswest3",
"US-WEST-3",
"us-west-three",
"123-west-3",
]
for (const invalidRegion of invalidRegions) {
fireEvent.change(customRegionInput, { target: { value: invalidRegion } })
// Should show format validation error
expect(screen.getByText("settings:providers.awsCustomRegion.validation.format")).toBeInTheDocument()
}
})
it("should accept valid region formats", () => {
const apiConfiguration: Partial<ProviderSettings> = {
awsRegion: "custom",
awsCustomRegion: "",
awsUseProfile: true,
}
render(
<Bedrock
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
/>,
)
const customRegionInput = screen.getByTestId("custom-region-input")
// Test various valid formats
const validRegions = [
"us-west-3",
"eu-central-2",
"ap-southeast-4",
"sa-east-2",
"ca-west-1",
"me-south-2",
"af-south-1",
]
for (const validRegion of validRegions) {
fireEvent.change(customRegionInput, { target: { value: validRegion } })
// Should update the field
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("awsCustomRegion", validRegion)
// Should not show any error
expect(
screen.queryByText("settings:providers.awsCustomRegion.validation.format"),
).not.toBeInTheDocument()
expect(
screen.queryByText("settings:providers.awsCustomRegion.validation.required"),
).not.toBeInTheDocument()
}
})
it("should preserve custom region value when switching between regions", () => {
const apiConfiguration: Partial<ProviderSettings> = {
awsRegion: "custom",
awsCustomRegion: "us-west-3",
awsUseProfile: true,
}
const { rerender } = render(
<Bedrock
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
/>,
)
// Custom region input should be visible with the value
let customRegionInput = screen.getByTestId("custom-region-input") as HTMLInputElement
expect(customRegionInput.value).toBe("us-west-3")
// Switch to a standard region by calling the onValueChange directly
if ((window as any).__selectOnValueChange) {
;(window as any).__selectOnValueChange("us-east-1")
}
// Update the configuration
apiConfiguration.awsRegion = "us-east-1"
rerender(
<Bedrock
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
/>,
)
// Custom region input should be hidden
expect(screen.queryByTestId("custom-region-input")).not.toBeInTheDocument()
// Switch back to custom region
if ((window as any).__selectOnValueChange) {
;(window as any).__selectOnValueChange("custom")
}
// Update the configuration
apiConfiguration.awsRegion = "custom"
rerender(
<Bedrock
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
/>,
)
// Custom region input should be visible again with the preserved value
customRegionInput = screen.getByTestId("custom-region-input") as HTMLInputElement
expect(customRegionInput.value).toBe("us-west-3")
})
it("should validate existing custom region when switching back to custom", () => {
const apiConfiguration: Partial<ProviderSettings> = {
awsRegion: "us-east-1",
awsCustomRegion: "invalid-region", // Invalid format
awsUseProfile: true,
}
const { rerender } = render(
<Bedrock
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
/>,
)
// Switch to custom region
if ((window as any).__selectOnValueChange) {
;(window as any).__selectOnValueChange("custom")
}
// Update the configuration
apiConfiguration.awsRegion = "custom"
rerender(
<Bedrock
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
/>,
)
// Should show validation error for the existing invalid value
expect(screen.getByText("settings:providers.awsCustomRegion.validation.format")).toBeInTheDocument()
})
})
})

View file

@ -273,6 +273,14 @@
"awsSessionToken": "AWS Session Token",
"awsRegion": "AWS Region",
"awsCrossRegion": "Use cross-region inference",
"awsCustomRegion": {
"placeholder": "Enter custom region (e.g., us-west-3)",
"examples": "Examples of new or custom regions:",
"validation": {
"required": "Custom region is required when 'Custom region...' is selected",
"format": "Region must be in format: region-direction-number (e.g., us-west-3, eu-central-2)"
}
},
"awsBedrockVpc": {
"useCustomVpcEndpoint": "Use custom VPC endpoint",
"vpcEndpointUrlPlaceholder": "Enter VPC Endpoint URL (optional)",
@ -686,6 +694,7 @@
"keyFilePath": "Enter Key File Path...",
"projectId": "Enter Project ID...",
"customArn": "Enter ARN (e.g. arn:aws:bedrock:us-east-1:123456789012:foundation-model/my-model)",
"customRegion": "Enter custom region (e.g., us-west-3)",
"baseUrl": "Enter base URL...",
"modelId": {
"lmStudio": "e.g. meta-llama-3.1-8b-instruct",