diff --git a/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx b/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx index 2ab26649afd..188678a5975 100644 --- a/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx @@ -30,8 +30,13 @@ describe('LlmProviderCreateEditModal', () => { { short_name: 'AWS', regions: [ - { name: 'eu-west-1', city: 'Dublin' }, - { name: 'eu-west-2', city: 'London' }, + { name: 'eu-west-1', city: 'Dublin', country_code: 'IE' }, + { name: 'eu-west-2', city: 'London', country_code: 'GB' }, + { name: 'us-east-2', city: 'Ohio', country_code: 'US' }, + { name: 'af-south-1', city: 'Cape Town', country_code: 'ZA' }, + { name: 'ap-east-1', city: 'Hong Kong', country_code: 'HK' }, + { name: 'ap-south-1', city: 'Mumbai', country_code: 'IN' }, + { name: 'ap-northeast-1', city: 'Tokyo', country_code: 'JP' }, ], }, { short_name: 'GCP', regions: [{ name: 'europe-west1', city: 'Belgium' }] }, @@ -129,6 +134,7 @@ describe('LlmProviderCreateEditModal', () => { const { userEvent } = renderWithProviders() await selectEvent.select(screen.getByLabelText('Provider'), 'Amazon Bedrock') + expect(document.querySelector('[data-testid="selected-icon"] img[src*="/ie.svg"]')).toBeInTheDocument() await userEvent.type(screen.getByLabelText('Name'), 'EU Bedrock') await userEvent.type(screen.getByLabelText('Token'), 'aws-credentials') await selectEvent.select(screen.getByLabelText('AWS region'), 'London (eu-west-2)', { @@ -151,6 +157,17 @@ describe('LlmProviderCreateEditModal', () => { ) }) + it('shows the country flag for every AWS region option', async () => { + renderWithProviders() + + await selectEvent.select(screen.getByLabelText('Provider'), 'Amazon Bedrock') + await selectEvent.openMenu(screen.getByLabelText('AWS region')) + + for (const code of ['ie', 'gb', 'us', 'za', 'hk', 'in', 'jp']) { + expect(document.querySelector(`img[src$="/${code}.svg"]`)).toBeInTheDocument() + } + }) + it('should select eu-west-1 as the default AWS region', async () => { const { userEvent } = renderWithProviders() diff --git a/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.tsx b/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.tsx index 25dc02936af..41bdcad00e2 100644 --- a/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.tsx +++ b/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.tsx @@ -8,7 +8,8 @@ import { } from 'qovery-typescript-axios' import { Controller, FormProvider, useForm } from 'react-hook-form' import { useCloudProviders } from '@qovery/domains/cloud-providers/feature' -import { InputSelect, InputText, InputTextArea, ModalCrud, useModal } from '@qovery/shared/ui' +import { IconFlag, InputSelect, InputText, InputTextArea, ModalCrud, useModal } from '@qovery/shared/ui' +import { getAwsLocationFlagCode } from '@qovery/shared/util-js' import { useCreateLlmProvider } from '../hooks/use-create-llm-provider/use-create-llm-provider' import { useEditLlmProvider } from '../hooks/use-edit-llm-provider/use-edit-llm-provider' @@ -55,10 +56,14 @@ const SCOPE_OPTIONS = [ function BedrockRegionSelect({ value, onChange }: { value: string; onChange: (value: string) => void }) { const { data: cloudProviders = [], isError, isLoading } = useCloudProviders() const awsRegions = cloudProviders.find(({ short_name }) => short_name === CloudProviderEnum.AWS)?.regions ?? [] - const options = awsRegions.map(({ city, name }) => ({ label: `${city} (${name})`, value: name })) + const options = awsRegions.map(({ city, country_code, name }) => { + const flagCode = country_code || getAwsLocationFlagCode(name) + return { label: `${city} (${name})`, value: name, icon: flagCode ? : undefined } + }) if (value && !options.some(({ value: region }) => region === value)) { - options.push({ label: value, value }) + const flagCode = getAwsLocationFlagCode(value) + options.push({ label: value, value, icon: flagCode ? : undefined }) } return ( diff --git a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.spec.tsx b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.spec.tsx index 07b94632c4f..c6a6a488c4c 100644 --- a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.spec.tsx +++ b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.spec.tsx @@ -30,6 +30,7 @@ const bedrockProvider: LlmProviderResponse = { id: 'provider-2', name: 'Bedrock token', type: LlmProviderType.BEDROCK, + region: 'eu-west-1', } describe('AgenticWorkflowAiConfigurationSettings', () => { @@ -104,13 +105,19 @@ describe('AgenticWorkflowAiConfigurationSettings', () => { it('shows the model selector for Bedrock', () => { useLlmProviderModelsSpy.mockReturnValue({ - data: [{ id: 'eu.anthropic.claude-opus-5', display_name: 'Claude Opus 5', created_at: null }], + data: [{ id: 'anthropic.claude-opus-5', display_name: 'Claude Opus 5', created_at: null }], isError: false, isLoading: false, }) renderWithProviders( - + {(form) => ( { ) expect(screen.getByLabelText('Model')).toBeInTheDocument() + expect(document.querySelector('[data-testid="selected-icon"] img[src*="/eu.svg"]')).toBeInTheDocument() expect(screen.queryByText('Cloud settings JSON')).not.toBeInTheDocument() }) }) diff --git a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.tsx b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.tsx index 0560aa457f1..75c5786455a 100644 --- a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.tsx +++ b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.tsx @@ -16,6 +16,7 @@ export function AgenticWorkflowAiConfigurationSettings({ }) { const llmProviderId = form.watch('llmProviderId') const currentModelType = form.watch('modelType') + const providerRegion = llmProviders.find(({ id }) => id === llmProviderId)?.region return ( <> @@ -47,6 +48,7 @@ export function AgenticWorkflowAiConfigurationSettings({ diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx index f52fa40219a..dfce8874fc2 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx @@ -1182,6 +1182,7 @@ export function AgenticWorkflowConfiguration() { id === values.llmProviderId)?.region} settings={field.value} onChange={field.onChange} /> diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-model-setting.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-model-setting.spec.tsx index f08f8ceb697..2ed65fc4008 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-model-setting.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-model-setting.spec.tsx @@ -64,6 +64,7 @@ describe('AgenticWorkflowModelSetting', () => { expect(screen.getByLabelText('Model')).toBeInTheDocument() expect(screen.queryByText('Bedrock settings')).not.toBeInTheDocument() + expect(screen.queryByTestId('selected-icon')).not.toBeInTheDocument() await selectEvent.select(screen.getByLabelText('Model'), 'Claude Sonnet', { container: document.body, }) @@ -126,7 +127,7 @@ describe('AgenticWorkflowModelSetting', () => { it('lists Bedrock models and serializes the first model', async () => { mockModels = [ { id: 'eu.anthropic.claude-opus-5', display_name: 'Claude Opus 5', created_at: null }, - { id: 'eu.anthropic.claude-sonnet-4', display_name: 'Claude Sonnet 4', created_at: null }, + { id: 'us.anthropic.claude-sonnet-4', display_name: 'Claude Sonnet 4', created_at: null }, ] const onChange = jest.fn() @@ -134,16 +135,55 @@ describe('AgenticWorkflowModelSetting', () => { ) expect(screen.getByLabelText('Model')).toBeInTheDocument() + await selectEvent.openMenu(screen.getByLabelText('Model')) + expect(document.querySelector('img[src*="/eu.svg"]')).toBeInTheDocument() + expect(document.querySelector('img[src*="/us.svg"]')).toBeInTheDocument() await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)) expect(JSON.parse(onChange.mock.calls[0][0])).toEqual({ model: 'eu.anthropic.claude-opus-5' }) }) + it('uses the Bedrock token region for a model without a geographic prefix', async () => { + mockModels = [{ id: 'anthropic.claude-sonnet-4', display_name: 'Claude Sonnet 4', created_at: null }] + + renderWithProviders( + + ) + + expect(document.querySelector('[data-testid="selected-icon"] img[src*="/in.svg"]')).toBeInTheDocument() + await selectEvent.openMenu(screen.getByLabelText('Model')) + expect(document.querySelector('img[src*="/in.svg"]')).toBeInTheDocument() + }) + + it('shows a globe for a global Bedrock model', () => { + mockModels = [{ id: 'global.anthropic.claude-sonnet-4', display_name: 'Claude Sonnet 4', created_at: null }] + + renderWithProviders( + + ) + + expect(document.querySelector('[data-testid="selected-icon"] .fa-globe')).toBeInTheDocument() + expect(document.querySelector('img[src*="/eu.svg"]')).not.toBeInTheDocument() + }) + it('repairs invalid Bedrock settings with the first available model', async () => { mockModels = [{ id: 'eu.anthropic.claude-opus-5', display_name: 'Claude Opus 5', created_at: null }] const onChange = jest.fn() diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-model-setting.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-model-setting.tsx index 98e4d2c1f63..c8702765061 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-model-setting.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-model-setting.tsx @@ -2,7 +2,8 @@ import { LlmProviderType, type LlmProviderType as LlmProviderTypeValue } from 'q import { useEffect, useRef } from 'react' import { match } from 'ts-pattern' import { useLlmProviderModels } from '@qovery/domains/organizations/feature' -import { InputSelect } from '@qovery/shared/ui' +import { Icon, IconFlag, InputSelect } from '@qovery/shared/ui' +import { getAwsLocationFlagCode } from '@qovery/shared/util-js' function parseModelSettings(value: string): Record | undefined { try { @@ -28,6 +29,7 @@ export function updateAgenticWorkflowModel(value: string, model: string) { export interface AgenticWorkflowModelSettingProps { llmProviderId: string providerType?: LlmProviderTypeValue + providerRegion?: string | null settings: string onChange: (value: string) => void } @@ -35,6 +37,7 @@ export interface AgenticWorkflowModelSettingProps { export function AgenticWorkflowModelSetting({ llmProviderId, providerType, + providerRegion, settings, onChange, }: AgenticWorkflowModelSettingProps) { @@ -96,7 +99,19 @@ export function AgenticWorkflowModelSetting({ if (!hasModelProvider) return null - const modelOptions = models.map(({ id, display_name }) => ({ value: id, label: display_name })) + const modelOptions = models.map(({ id, display_name }) => { + const isCrossRegionProfile = providerType === LlmProviderType.BEDROCK && /^(global|apac)\./.test(id) + const flagCode = + providerType === LlmProviderType.BEDROCK && !isCrossRegionProfile + ? getAwsLocationFlagCode(id) ?? getAwsLocationFlagCode(providerRegion ?? '') + : undefined + const icon = flagCode ? ( + + ) : isCrossRegionProfile ? ( + + ) : undefined + return { value: id, label: display_name, icon } + }) const hasModelsError = isError || (!isLoading && models.length === 0) const modelsError = match([isError, providerType === LlmProviderType.BEDROCK]) .with([true, true], () => 'We couldn’t load models. Check this token’s AWS credentials and region.') diff --git a/libs/shared/util-js/src/index.ts b/libs/shared/util-js/src/index.ts index bed190b8758..df938c89e6a 100644 --- a/libs/shared/util-js/src/index.ts +++ b/libs/shared/util-js/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/aws-location-flag-code' export * from './lib/build-git-repo-url' export * from './lib/compute-available-environment-variable-scope' export * from './lib/container-registry-kind-to-icon' diff --git a/libs/shared/util-js/src/lib/aws-location-flag-code.spec.ts b/libs/shared/util-js/src/lib/aws-location-flag-code.spec.ts new file mode 100644 index 00000000000..c32593b0831 --- /dev/null +++ b/libs/shared/util-js/src/lib/aws-location-flag-code.spec.ts @@ -0,0 +1,18 @@ +import { getAwsLocationFlagCode } from './aws-location-flag-code' + +describe('getAwsLocationFlagCode', () => { + it.each([ + ['eu-west-1', 'EU'], + ['eu.anthropic.claude-sonnet', 'EU'], + ['us-east-1', 'US'], + ['us.anthropic.claude-sonnet', 'US'], + ['af-south-1', 'ZA'], + ['ap-east-1', 'HK'], + ['ap-south-2', 'IN'], + ['ap-northeast-1', 'JP'], + ['ap-southeast-1', 'SG'], + ['global.anthropic.claude-sonnet', undefined], + ])('maps %s to %s', (location, expected) => { + expect(getAwsLocationFlagCode(location)).toBe(expected) + }) +}) diff --git a/libs/shared/util-js/src/lib/aws-location-flag-code.ts b/libs/shared/util-js/src/lib/aws-location-flag-code.ts new file mode 100644 index 00000000000..5403b2b32fa --- /dev/null +++ b/libs/shared/util-js/src/lib/aws-location-flag-code.ts @@ -0,0 +1,28 @@ +const AWS_REGION_COUNTRY_CODES: Record = { + 'af-south-1': 'ZA', + 'ap-east-1': 'HK', + 'ap-northeast-1': 'JP', + 'ap-northeast-2': 'KR', + 'ap-northeast-3': 'JP', + 'ap-south-1': 'IN', + 'ap-south-2': 'IN', + 'ap-southeast-1': 'SG', + 'ap-southeast-2': 'AU', + 'ap-southeast-3': 'ID', + 'ap-southeast-4': 'AU', + 'ca-central-1': 'CA', + 'ca-west-1': 'CA', + 'cn-north-1': 'CN', + 'cn-northwest-1': 'CN', + 'il-central-1': 'IL', + 'me-central-1': 'AE', + 'me-south-1': 'BH', + 'mx-central-1': 'MX', + 'sa-east-1': 'BR', +} + +export function getAwsLocationFlagCode(location: string): string | undefined { + if (/^eu[.-]/.test(location)) return 'EU' + if (/^us[.-]/.test(location)) return 'US' + return AWS_REGION_COUNTRY_CODES[location] +}