diff --git a/src/api/providers/__tests__/bedrock-custom-region.spec.ts b/src/api/providers/__tests__/bedrock-custom-region.spec.ts new file mode 100644 index 0000000000..522d01c1b2 --- /dev/null +++ b/src/api/providers/__tests__/bedrock-custom-region.spec.ts @@ -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) + } + }) +}) diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index a25fea5200..3ace00f525 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -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}` + } } } } diff --git a/webview-ui/src/components/settings/providers/Bedrock.tsx b/webview-ui/src/components/settings/providers/Bedrock.tsx index 86bb225111..1b51ea594e 100644 --- a/webview-ui/src/components/settings/providers/Bedrock.tsx +++ b/webview-ui/src/components/settings/providers/Bedrock.tsx @@ -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(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) => { + const target = event.target as HTMLInputElement + const value = target.value + validateCustomRegion(value) + setApiConfigurationField("awsCustomRegion", value) + }, + [setApiConfigurationField, validateCustomRegion], + ) + return ( <> { 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) } }}> @@ -120,10 +156,14 @@ export const Bedrock = ({ apiConfiguration, setApiConfigurationField, selectedMo + {customRegionError && ( +
{customRegionError}
+ )}
{t("settings:providers.awsCustomRegion.examples")}
• us-west-3
diff --git a/webview-ui/src/components/settings/providers/__tests__/Bedrock.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Bedrock.spec.tsx index 80c1c4fd90..a2f6774563 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Bedrock.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Bedrock.spec.tsx @@ -61,11 +61,18 @@ vi.mock("@src/i18n/TranslationContext", () => ({ // Mock the UI components vi.mock("@src/components/ui", () => ({ - Select: ({ children }: any) =>
{children}
, + Select: ({ children, onValueChange }: any) => { + // Store the onValueChange callback on the window for testing + if (typeof window !== "undefined") { + ;(window as any).__selectOnValueChange = onValueChange + } + return
{children}
+ }, SelectContent: ({ children }: any) =>
{children}
, SelectItem: () =>
Item
, - SelectTrigger: ({ children }: any) =>
{children}
, + SelectTrigger: ({ children }: any) =>
{children}
, SelectValue: () =>
Value
, + StandardTooltip: ({ children }: any) =>
{children}
, })) // 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 = { + awsRegion: "custom", + awsCustomRegion: "", + awsUseProfile: true, + } + + render( + , + ) + + // 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 = { + awsRegion: "custom", + awsCustomRegion: "", + awsUseProfile: true, + } + + render( + , + ) + + 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 = { + awsRegion: "custom", + awsCustomRegion: "", + awsUseProfile: true, + } + + render( + , + ) + + 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 = { + awsRegion: "custom", + awsCustomRegion: "us-west-3", + awsUseProfile: true, + } + + const { rerender } = render( + , + ) + + // 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( + , + ) + + // 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( + , + ) + + // 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 = { + awsRegion: "us-east-1", + awsCustomRegion: "invalid-region", // Invalid format + awsUseProfile: true, + } + + const { rerender } = render( + , + ) + + // Switch to custom region + if ((window as any).__selectOnValueChange) { + ;(window as any).__selectOnValueChange("custom") + } + + // Update the configuration + apiConfiguration.awsRegion = "custom" + rerender( + , + ) + + // Should show validation error for the existing invalid value + expect(screen.getByText("settings:providers.awsCustomRegion.validation.format")).toBeInTheDocument() + }) + }) }) diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 728e856502..29b8ea497b 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -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",