Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] },
Expand Down Expand Up @@ -129,6 +134,7 @@ describe('LlmProviderCreateEditModal', () => {
const { userEvent } = renderWithProviders(<LlmProviderCreateEditModal onClose={jest.fn()} />)

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)', {
Expand All @@ -151,6 +157,17 @@ describe('LlmProviderCreateEditModal', () => {
)
})

it('shows the country flag for every AWS region option', async () => {
renderWithProviders(<LlmProviderCreateEditModal onClose={jest.fn()} />)

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(<LlmProviderCreateEditModal onClose={jest.fn()} />)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 ? <IconFlag code={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 ? <IconFlag code={flagCode} /> : undefined })
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const bedrockProvider: LlmProviderResponse = {
id: 'provider-2',
name: 'Bedrock token',
type: LlmProviderType.BEDROCK,
region: 'eu-west-1',
}

describe('AgenticWorkflowAiConfigurationSettings', () => {
Expand Down Expand Up @@ -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(
<AgenticWorkflowSettingsFormHarness values={{ llmProviderId: bedrockProvider.id, modelSettings: '' }}>
<AgenticWorkflowSettingsFormHarness
values={{
llmProviderId: bedrockProvider.id,
modelType: AgenticWorkflowModelType.BEDROCK,
modelSettings: '{"model":"anthropic.claude-opus-5"}',
}}
>
{(form) => (
<AgenticWorkflowAiConfigurationSettings
form={form}
Expand All @@ -122,6 +129,7 @@ describe('AgenticWorkflowAiConfigurationSettings', () => {
)

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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand Down Expand Up @@ -47,6 +48,7 @@ export function AgenticWorkflowAiConfigurationSettings({
<AgenticWorkflowModelSetting
llmProviderId={llmProviderId}
providerType={currentModelType ?? modelType}
providerRegion={providerRegion}
settings={field.value}
onChange={field.onChange}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,7 @@ export function AgenticWorkflowConfiguration() {
<AgenticWorkflowModelSetting
llmProviderId={values.llmProviderId}
providerType={selectedProviderType}
providerRegion={availableLlmProviders.find(({ id }) => id === values.llmProviderId)?.region}
settings={field.value}
onChange={field.onChange}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -126,24 +127,63 @@ 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()

renderWithProviders(
<AgenticWorkflowModelSetting
llmProviderId="provider-1"
providerType={LlmProviderType.BEDROCK}
providerRegion="us-east-1"
settings="{}"
onChange={onChange}
/>
)

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(
<AgenticWorkflowModelSetting
llmProviderId="provider-1"
providerType={LlmProviderType.BEDROCK}
providerRegion="ap-south-1"
settings={'{"model":"anthropic.claude-sonnet-4"}'}
onChange={jest.fn()}
/>
)

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(
<AgenticWorkflowModelSetting
llmProviderId="provider-1"
providerType={LlmProviderType.BEDROCK}
providerRegion="eu-west-1"
settings={'{"model":"global.anthropic.claude-sonnet-4"}'}
onChange={jest.fn()}
/>
)

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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined {
try {
Expand All @@ -28,13 +29,15 @@ export function updateAgenticWorkflowModel(value: string, model: string) {
export interface AgenticWorkflowModelSettingProps {
llmProviderId: string
providerType?: LlmProviderTypeValue
providerRegion?: string | null
settings: string
onChange: (value: string) => void
}

export function AgenticWorkflowModelSetting({
llmProviderId,
providerType,
providerRegion,
settings,
onChange,
}: AgenticWorkflowModelSettingProps) {
Expand Down Expand Up @@ -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 ? (
<IconFlag code={flagCode} />
) : isCrossRegionProfile ? (
<Icon iconName="globe" className="text-xs" />
) : 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.')
Expand Down
1 change: 1 addition & 0 deletions libs/shared/util-js/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
18 changes: 18 additions & 0 deletions libs/shared/util-js/src/lib/aws-location-flag-code.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
28 changes: 28 additions & 0 deletions libs/shared/util-js/src/lib/aws-location-flag-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const AWS_REGION_COUNTRY_CODES: Record<string, string> = {
'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]
}
Loading