({
+ value: id,
+ label: name,
+ description,
+ }))}
+ isClearable
+ isSearchable
+ isLoading={isLoading}
+ placeholder="Select a token"
+ hint={
+ llmProviders.length === 0 && !isLoading ? (
+
+ No token is configured. Create one here or manage tokens in{' '}
+
+ Agents → Tokens
+
+ .
+
+ ) : (
+ 'Select the token this agent task will use.'
+ )
+ }
+ menuListButton={{
+ title: 'Select token',
+ label: 'New token',
+ onClick: openCreateModal,
+ }}
+ onChange={(nextValue) => onChange(typeof nextValue === 'string' ? nextValue : '')}
+ />
+ )
+}
+
+export default LlmProviderSetting
diff --git a/libs/domains/organizations/feature/src/lib/mcp-server-setting/mcp-server-setting.tsx b/libs/domains/organizations/feature/src/lib/mcp-server-setting/mcp-server-setting.tsx
index 190aa1756bf..7228f2ec1cf 100644
--- a/libs/domains/organizations/feature/src/lib/mcp-server-setting/mcp-server-setting.tsx
+++ b/libs/domains/organizations/feature/src/lib/mcp-server-setting/mcp-server-setting.tsx
@@ -54,9 +54,9 @@ export function McpServerSetting({ isLoading, mcpServers, organizationId, value,
No MCP is configured. Create one here or manage MCPs in{' '}
- AI settings → Agents
+ Agents → MCPs
.
diff --git a/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.spec.tsx
index 1dbc7758f3a..423ece8ac1f 100644
--- a/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.spec.tsx
+++ b/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.spec.tsx
@@ -1,26 +1,42 @@
-import { type McpServerResponse, McpServerScope } from 'qovery-typescript-axios'
-import { type ReactElement } from 'react'
+import {
+ type LlmProviderResponse,
+ LlmProviderScope,
+ LlmProviderType,
+ type McpServerResponse,
+ McpServerScope,
+} from 'qovery-typescript-axios'
+import { type ReactElement, type ReactNode } from 'react'
import * as sharedUi from '@qovery/shared/ui'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
+import * as useDeleteLlmProviderHook from '../hooks/use-delete-llm-provider/use-delete-llm-provider'
import * as useDeleteMcpServerHook from '../hooks/use-delete-mcp-server/use-delete-mcp-server'
+import * as useLlmProvidersHook from '../hooks/use-llm-providers/use-llm-providers'
import * as useMcpServersHook from '../hooks/use-mcp-servers/use-mcp-servers'
import { type McpServerCreateEditModalProps } from '../mcp-server-create-edit-modal/mcp-server-create-edit-modal'
-import { SettingsAgentPersonalization } from './settings-agent-personalization'
+import { SettingsAgentMcps, SettingsAgentTokens } from './settings-agent-personalization'
const useMcpServersMock = jest.spyOn(useMcpServersHook, 'useMcpServers') as jest.Mock
const useDeleteMcpServerMock = jest.spyOn(useDeleteMcpServerHook, 'useDeleteMcpServer') as jest.Mock
+const useLlmProvidersMock = jest.spyOn(useLlmProvidersHook, 'useLlmProviders') as jest.Mock
+const useDeleteLlmProviderMock = jest.spyOn(useDeleteLlmProviderHook, 'useDeleteLlmProvider') as jest.Mock
const useModalMock = jest.spyOn(sharedUi, 'useModal') as jest.Mock
const useModalConfirmationMock = jest.spyOn(sharedUi, 'useModalConfirmation') as jest.Mock
const openModal = jest.fn()
const closeModal = jest.fn()
const openModalConfirmation = jest.fn()
const deleteMcpServer = jest.fn()
+const deleteLlmProvider = jest.fn()
jest.mock('@tanstack/react-router', () => ({
...jest.requireActual('@tanstack/react-router'),
useParams: () => ({ organizationId: 'org-1' }),
}))
+jest.mock('@auth0/auth0-react', () => ({
+ Auth0Provider: ({ children }: { children: ReactNode }) => children,
+ useAuth0: () => ({ user: { sub: 'auth0|current-user' } }),
+}))
+
const mcpServers: McpServerResponse[] = [
{
id: 'mcp-zulu',
@@ -59,36 +75,72 @@ const mcpServers: McpServerResponse[] = [
},
]
-describe('SettingsAgentPersonalization', () => {
+const llmProviders: LlmProviderResponse[] = [
+ {
+ id: 'provider-claude',
+ name: 'Claude production',
+ description: 'Production credential',
+ type: LlmProviderType.CLAUDE,
+ has_credential: true,
+ scope: LlmProviderScope.ORGANIZATION,
+ created_at: '2026-09-15T10:00:00Z',
+ updated_at: '2026-09-15T10:00:00Z',
+ },
+ {
+ id: 'provider-personal',
+ name: 'My Claude',
+ description: '',
+ type: LlmProviderType.CLAUDE,
+ has_credential: false,
+ scope: LlmProviderScope.USER,
+ owner_name: 'Rémi Bonnet',
+ owner_user_sub: 'auth0|current-user',
+ created_at: '2026-09-15T10:00:00Z',
+ updated_at: '2026-09-15T10:00:00Z',
+ },
+ {
+ id: 'provider-other-user',
+ name: 'Other Claude',
+ description: '',
+ type: LlmProviderType.CLAUDE,
+ has_credential: true,
+ scope: LlmProviderScope.USER,
+ owner_name: 'Another member',
+ owner_user_sub: 'auth0|other-user',
+ created_at: '2026-09-15T10:00:00Z',
+ updated_at: '2026-09-15T10:00:00Z',
+ },
+]
+
+describe('Agent settings pages', () => {
beforeEach(() => {
jest.useFakeTimers()
jest.clearAllMocks()
useModalMock.mockReturnValue({ openModal, closeModal })
useModalConfirmationMock.mockReturnValue({ openModalConfirmation })
useDeleteMcpServerMock.mockReturnValue({ mutateAsync: deleteMcpServer })
+ useDeleteLlmProviderMock.mockReturnValue({ mutateAsync: deleteLlmProvider })
+ useLlmProvidersMock.mockReturnValue({ data: [] })
})
afterEach(() => {
jest.useRealTimers()
})
- it('should render the empty state and organization scope', () => {
+ it('should render the token empty state', () => {
useMcpServersMock.mockReturnValue({ data: [] })
- renderWithProviders()
+ renderWithProviders()
- expect(screen.getByRole('heading', { name: 'Agent personalization' })).toBeInTheDocument()
- expect(screen.getByText('Your personal settings for Qovery Agent')).toBeInTheDocument()
- expect(
- screen.queryByText('Personal MCPs belong to one member. Organization MCPs are shared with the organization.')
- ).not.toBeInTheDocument()
- expect(screen.getByText('No MCPs')).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: 'Tokens' })).toBeInTheDocument()
+ expect(screen.getByText('No tokens')).toBeInTheDocument()
+ expect(screen.queryByText('No MCPs')).not.toBeInTheDocument()
})
it('should render MCPs alphabetically with their URL, details tooltip, and actions', async () => {
useMcpServersMock.mockReturnValue({ data: mcpServers })
- const { userEvent } = renderWithProviders()
+ const { userEvent } = renderWithProviders()
const rows = screen.getAllByTestId(/^mcp-server-/)
expect(rows[0]).toHaveAttribute('data-testid', 'mcp-server-mcp-zulu')
@@ -97,6 +149,7 @@ describe('SettingsAgentPersonalization', () => {
expect(screen.getByText('Personal MCPs')).toBeInTheDocument()
expect(screen.getByText('Organization MCPs')).toBeInTheDocument()
expect(screen.getByText('Owner: Rémi Bonnet')).toHaveClass('min-w-0', 'truncate')
+ expect(screen.getByText('Owner: Rémi Bonnet').parentElement).toContainElement(screen.getByText('Alpha'))
expect(screen.getByText('https://zulu.example.com/mcp')).toBeInTheDocument()
expect(screen.queryByText('Authorization')).not.toBeInTheDocument()
expect(screen.queryByText('Second connector')).not.toBeInTheDocument()
@@ -112,7 +165,7 @@ describe('SettingsAgentPersonalization', () => {
it('should hide the personal MCP section when there are no personal MCPs', () => {
useMcpServersMock.mockReturnValue({ data: [mcpServers[0]] })
- renderWithProviders()
+ renderWithProviders()
expect(screen.queryByText('Personal MCPs')).not.toBeInTheDocument()
expect(screen.getByText('Organization MCPs')).toBeInTheDocument()
@@ -121,7 +174,7 @@ describe('SettingsAgentPersonalization', () => {
it('should open the create and edit modals', async () => {
useMcpServersMock.mockReturnValue({ data: mcpServers })
- const { userEvent } = renderWithProviders()
+ const { userEvent } = renderWithProviders()
await userEvent.click(screen.getByRole('button', { name: 'Add MCP' }))
await userEvent.click(screen.getByRole('button', { name: 'Edit Zulu' }))
@@ -133,9 +186,77 @@ describe('SettingsAgentPersonalization', () => {
expect(openModal).toHaveBeenNthCalledWith(2, expect.objectContaining({ options: { fakeModal: true, width: 680 } }))
})
+ it('should keep each page focused on its own action', () => {
+ useMcpServersMock.mockReturnValue({ data: [] })
+
+ const tokenPage = renderWithProviders()
+ expect(screen.getByRole('button', { name: 'Add token' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Add MCP' })).not.toBeInTheDocument()
+
+ tokenPage.unmount()
+ renderWithProviders()
+ expect(screen.getByRole('button', { name: 'Add MCP' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Add token' })).not.toBeInTheDocument()
+ })
+
+ it('should render and manage provider tokens', async () => {
+ useMcpServersMock.mockReturnValue({ data: [] })
+ useLlmProvidersMock.mockReturnValue({ data: llmProviders })
+ const { userEvent } = renderWithProviders()
+
+ expect(screen.getByText('Organization tokens')).toBeInTheDocument()
+ expect(screen.getByText('Personal token')).toBeInTheDocument()
+ expect(screen.queryByText('Claude')).not.toBeInTheDocument()
+ expect(screen.queryByText('Configured')).not.toBeInTheDocument()
+ expect(screen.getByText('No token')).toBeInTheDocument()
+ expect(screen.queryByText('Organization')).not.toBeInTheDocument()
+ expect(screen.queryByText('Production credential')).not.toBeInTheDocument()
+ expect(screen.getByLabelText('About Claude production')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Edit My Claude' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Delete My Claude' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Edit Other Claude' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Delete Other Claude' })).not.toBeInTheDocument()
+ expect(screen.getByText('Owner: Another member')).toBeInTheDocument()
+ expect(screen.getByText('Owner: Another member').previousElementSibling).toContainElement(
+ screen.getByText('Other Claude')
+ )
+ expect(document.querySelectorAll('img[src="/assets/ai-tools/claude.svg"]')).toHaveLength(3)
+
+ await userEvent.hover(screen.getByLabelText('About Claude production'))
+ expect((await screen.findAllByText('Production credential')).length).toBeGreaterThan(0)
+
+ await userEvent.click(screen.getByRole('button', { name: 'Add token' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Edit Claude production' }))
+ expect(openModal).toHaveBeenCalledTimes(2)
+
+ await userEvent.click(screen.getByRole('button', { name: 'Delete Claude production' }))
+ const confirmation = openModalConfirmation.mock.calls[0][0]
+ await confirmation.action()
+ expect(confirmation).toEqual(expect.objectContaining({ title: 'Delete token', name: 'Claude production' }))
+ expect(deleteLlmProvider).toHaveBeenCalledWith({
+ organizationId: 'org-1',
+ llmProviderId: 'provider-claude',
+ })
+ })
+
+ it('should hide empty token scope sections', () => {
+ useLlmProvidersMock.mockReturnValue({ data: [llmProviders[0]] })
+ const organizationTokens = renderWithProviders()
+
+ expect(screen.getByText('Organization tokens')).toBeInTheDocument()
+ expect(screen.queryByText('Personal token')).not.toBeInTheDocument()
+
+ organizationTokens.unmount()
+ useLlmProvidersMock.mockReturnValue({ data: [llmProviders[1]] })
+ renderWithProviders()
+
+ expect(screen.queryByText('Organization tokens')).not.toBeInTheDocument()
+ expect(screen.getByText('Personal token')).toBeInTheDocument()
+ })
+
it('should confirm deletion with the connector name and organization scope', async () => {
useMcpServersMock.mockReturnValue({ data: mcpServers })
- const { userEvent } = renderWithProviders()
+ const { userEvent } = renderWithProviders()
await userEvent.click(screen.getByRole('button', { name: 'Delete Zulu' }))
const confirmation = openModalConfirmation.mock.calls[0][0]
diff --git a/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx b/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx
index 1b4e3ce9f98..11c5a05af45 100644
--- a/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx
+++ b/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx
@@ -1,8 +1,16 @@
+import { useAuth0 } from '@auth0/auth0-react'
import { useParams } from '@tanstack/react-router'
-import { type McpServerResponse, McpServerScope } from 'qovery-typescript-axios'
-import { Suspense, useMemo } from 'react'
+import {
+ type LlmProviderResponse,
+ LlmProviderScope,
+ LlmProviderType,
+ type McpServerResponse,
+ McpServerScope,
+} from 'qovery-typescript-axios'
+import { type ReactNode, Suspense, useMemo } from 'react'
import { SettingsHeading } from '@qovery/shared/console-shared'
import {
+ Badge,
BlockContent,
Button,
EmptyState,
@@ -16,8 +24,11 @@ import {
useModalConfirmation,
} from '@qovery/shared/ui'
import { useDocumentTitle } from '@qovery/shared/util-hooks'
+import { useDeleteLlmProvider } from '../hooks/use-delete-llm-provider/use-delete-llm-provider'
import { useDeleteMcpServer } from '../hooks/use-delete-mcp-server/use-delete-mcp-server'
+import { useLlmProviders } from '../hooks/use-llm-providers/use-llm-providers'
import { useMcpServers } from '../hooks/use-mcp-servers/use-mcp-servers'
+import { LlmProviderCreateEditModal } from '../llm-provider-create-edit-modal/llm-provider-create-edit-modal'
import { McpServerCreateEditModal } from '../mcp-server-create-edit-modal/mcp-server-create-edit-modal'
interface McpServerRowProps {
@@ -73,28 +84,13 @@ function McpServerRow({ organizationId, mcpServer }: McpServerRowProps) {
) : null}
-
-
-
{mcpServer.url}
{owner ? (
- <>
-
-
- {owner}
-
- >
+
+ {owner}
+
) : null}
+ {mcpServer.url}
{mcpServer.scope === McpServerScope.ORGANIZATION || mcpServer.attachable ? (
@@ -143,18 +139,199 @@ function McpServersSkeleton() {
)
}
+interface LlmProviderRowProps {
+ organizationId: string
+ llmProvider: LlmProviderResponse
+ currentUserSub?: string
+}
+
+function LlmProviderRow({ organizationId, llmProvider, currentUserSub }: LlmProviderRowProps) {
+ const { openModal, closeModal } = useModal()
+ const { openModalConfirmation } = useModalConfirmation()
+ const { mutateAsync: deleteLlmProvider } = useDeleteLlmProvider()
+ const owner =
+ llmProvider.scope === LlmProviderScope.USER ? `Owner: ${llmProvider.owner_name ?? 'Unknown member'}` : undefined
+ const canManage =
+ llmProvider.scope === LlmProviderScope.ORGANIZATION ||
+ Boolean(currentUserSub && llmProvider.owner_user_sub === currentUserSub)
+
+ const onEdit = () => {
+ openModal({
+ content:
,
+ options: { fakeModal: true, width: 680 },
+ })
+ }
+
+ const onDelete = () => {
+ openModalConfirmation({
+ title: 'Delete token',
+ confirmationMethod: 'action',
+ name: llmProvider.name,
+ action: async () => {
+ try {
+ await deleteLlmProvider({ organizationId, llmProviderId: llmProvider.id })
+ } catch (error) {
+ console.error(error)
+ }
+ },
+ })
+ }
+
+ return (
+
+
+
+

+
+
+
+ {llmProvider.description ? (
+
+
+
+
+
+ ) : null}
+ {!llmProvider.has_credential ?
No token : null}
+
+ {owner ? {owner}
: null}
+
+ {canManage ? (
+
+
+
+
+ ) : null}
+
+ )
+}
+
+function LlmProvidersSkeleton() {
+ return (
+
+ {[0, 1].map((index) => (
+
+ ))}
+
+ )
+}
+
+interface ScopedItem {
+ id: string
+ name: string
+ scope: string
+}
+
+interface ScopedItemsListProps
{
+ items: T[]
+ organizationScope: string
+ userScope: string
+ organizationTitle: string
+ personalTitle: string
+ renderItem: (item: T) => ReactNode
+}
+
+function ScopedItemsList({
+ items,
+ organizationScope,
+ userScope,
+ organizationTitle,
+ personalTitle,
+ renderItem,
+}: ScopedItemsListProps) {
+ const sortedItems = useMemo(() => [...items].sort((first, second) => first.name.localeCompare(second.name)), [items])
+ const personalItems = sortedItems.filter(({ scope }) => scope === userScope)
+ const organizationItems = sortedItems.filter(({ scope }) => scope === organizationScope)
+ const itemGroup = (title: string, groupedItems: T[]) => (
+
+ {groupedItems.map(renderItem)}
+
+ )
+
+ return (
+
+ {organizationItems.length > 0 ? itemGroup(organizationTitle, organizationItems) : null}
+ {personalItems.length > 0 ? itemGroup(personalTitle, personalItems) : null}
+
+ )
+}
+
+function LlmProvidersList({ organizationId }: { organizationId: string }) {
+ const { data: llmProviders = [] } = useLlmProviders({ organizationId, suspense: true })
+ const { user } = useAuth0()
+
+ if (llmProviders.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+ (
+
+ )}
+ />
+ )
+}
+
interface McpServersListProps {
organizationId: string
}
function McpServersList({ organizationId }: McpServersListProps) {
const { data: mcpServers = [] } = useMcpServers({ organizationId, suspense: true })
- const sortedMcpServers = useMemo(
- () => [...mcpServers].sort((first, second) => first.name.localeCompare(second.name)),
- [mcpServers]
- )
- if (sortedMcpServers.length === 0) {
+ if (mcpServers.length === 0) {
return (
scope === McpServerScope.USER)
- const organizationMcpServers = sortedMcpServers.filter(({ scope }) => scope === McpServerScope.ORGANIZATION)
-
- const mcpServerGroup = (title: string, servers: McpServerResponse[], emptyMessage: string) => (
-
- {servers.length > 0 ? (
-
- {servers.map((mcpServer) => (
-
- ))}
-
- ) : (
- {emptyMessage}
+ return (
+ (
+
)}
-
+ />
)
+}
+
+export function SettingsAgentTokens() {
+ useDocumentTitle('Agent tokens - Organization settings')
+ const { organizationId = '' } = useParams({ strict: false })
+ const { openModal, closeModal } = useModal()
+
+ const onAddToken = () => {
+ openModal({
+ content: ,
+ options: { fakeModal: true, width: 680 },
+ })
+ }
return (
-
- {mcpServerGroup('Organization MCPs', organizationMcpServers, 'No organization MCPs.')}
- {personalMcpServers.length > 0 ? mcpServerGroup('Personal MCPs', personalMcpServers, '') : null}
-
+
+
+
+
+
+
+ }>
+
+
+
+
+
)
}
-export function SettingsAgentPersonalization() {
- useDocumentTitle('Agent personalization - Organization settings')
+export function SettingsAgentMcps() {
+ useDocumentTitle('Agent MCPs - Organization settings')
const { organizationId = '' } = useParams({ strict: false })
const { openModal, closeModal } = useModal()
- const onAdd = () => {
+ const onAddMcp = () => {
openModal({
content: ,
options: {
@@ -205,22 +402,20 @@ export function SettingsAgentPersonalization() {
}
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 24043a4e817..21f3566b991 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
@@ -2,15 +2,20 @@ import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { AgenticWorkflowSettingsFormHarness } from '../agentic-workflow-settings-test-utils'
import { AgenticWorkflowAiConfigurationSettings } from './agentic-workflow-ai-configuration-settings'
+jest.mock('@tanstack/react-router', () => ({
+ useParams: () => ({ environmentId: 'environment-1', organizationId: 'organization-1' }),
+}))
+
describe('AgenticWorkflowAiConfigurationSettings', () => {
- it('renders the write-only API key, model settings, and instructions', () => {
+ it('renders the token, model settings, and instructions', () => {
renderWithProviders(
- {(form) => }
+ {(form) => }
)
- expect(screen.getByLabelText('API key')).toHaveValue('')
+ expect(screen.getByLabelText('Token')).toBeInTheDocument()
+ expect(screen.queryByLabelText('API key')).not.toBeInTheDocument()
expect(screen.getByText('Cloud settings JSON')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Instructions' })).not.toBeInTheDocument()
const instructions = screen.getByRole('textbox', { name: 'Instructions' })
@@ -21,7 +26,7 @@ describe('AgenticWorkflowAiConfigurationSettings', () => {
it('does not show an instructions error before the field is modified', () => {
renderWithProviders(
- {(form) => }
+ {(form) => }
)
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 ae516255cad..1be7f47eecf 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
@@ -1,6 +1,7 @@
+import { type LlmProviderResponse } from 'qovery-typescript-axios'
import { Controller, type UseFormReturn } from 'react-hook-form'
+import { LlmProviderSetting } from '@qovery/domains/organizations/feature'
import { AgenticWorkflowCodeEditorField, AgenticWorkflowPromptEditor } from '@qovery/domains/services/feature'
-import { InputText } from '@qovery/shared/ui'
import { type AgenticWorkflowSettingsFormValues } from '../agentic-workflow-settings'
import { AgenticWorkflowSettingsCard } from '../agentic-workflow-settings-card'
@@ -14,11 +15,11 @@ function getJsonError(value: string) {
}
export function AgenticWorkflowAiConfigurationSettings({
- environmentId,
form,
+ llmProviders,
}: {
- environmentId: string
form: UseFormReturn
+ llmProviders: LlmProviderResponse[]
}) {
return (
<>
@@ -27,10 +28,10 @@ export function AgenticWorkflowAiConfigurationSettings({
description="Configure the Anthropic credentials and cloud settings."
>
(
-
+
)}
/>
(
{
editService.mockReset()
useServiceSpy.mockReturnValue({ data: service })
useGitTokensSpy.mockReturnValue({ data: [{ id: 'token-1', type: 'GITHUB' }], isLoading: false })
+ useLlmProvidersSpy.mockReturnValue({ data: [] })
useMcpServersSpy.mockReturnValue({
data: [{ id: 'mcp-1', name: 'Documentation', url: 'https://docs.example.com' }],
isLoading: false,
@@ -186,7 +189,11 @@ describe('AgenticWorkflowSettings views', () => {
payload: expect.objectContaining({
description: 'Updated description',
execution_mode: AgenticWorkflowExecutionMode.IN_PLACE,
- model: { type: AgenticWorkflowModelType.BEDROCK, settings: '{"temperature":0.2}' },
+ model: {
+ type: AgenticWorkflowModelType.BEDROCK,
+ llm_provider_id: 'provider-1',
+ settings: '{"temperature":0.2}',
+ },
mcp: '{"mcpServers":{}}',
outputs: [{ name: 'Audit log', url: null }],
mcp_servers: [{ id: 'mcp-1', required: true }],
@@ -215,11 +222,12 @@ describe('AgenticWorkflowSettings views', () => {
)
})
- it('renders AI configuration without exposing the write-only API key', () => {
+ it('renders AI configuration with a stored token only', () => {
renderWithProviders()
expect(screen.getByRole('heading', { name: 'AI configuration' })).toBeInTheDocument()
- expect(screen.getByLabelText('API key')).toHaveValue('')
+ expect(screen.getByLabelText('Token')).toBeInTheDocument()
+ expect(screen.queryByLabelText('API key')).not.toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Cloud settings JSON' })).toHaveValue('{"temperature":0.2}')
expect(screen.getByRole('textbox', { name: 'Instructions' })).toBeInTheDocument()
})
diff --git a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.tsx b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.tsx
index c85d9b73a45..088d78d1ee7 100644
--- a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.tsx
+++ b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.tsx
@@ -3,9 +3,10 @@ import {
AgenticWorkflowExecutionMode,
type AgenticWorkflowRequest,
type GitTokenResponse,
+ LlmProviderType,
} from 'qovery-typescript-axios'
import { useForm } from 'react-hook-form'
-import { useGitTokens } from '@qovery/domains/organizations/feature'
+import { useGitTokens, useLlmProviders } from '@qovery/domains/organizations/feature'
import { isAgenticWorkflow } from '@qovery/domains/services/data-access'
import {
type AgenticWorkflowAutomation,
@@ -34,7 +35,7 @@ export interface AgenticWorkflowSettingsFormValues {
description: string
enabled: boolean
executionMode: AgenticWorkflowExecutionMode
- modelApiKey: string
+ llmProviderId: string
modelSettings: string
agentPrompt: string
repositories: AgenticWorkflowGitRepository[]
@@ -127,6 +128,7 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps)
isLoading: contextServicesLoading,
} = useAgenticWorkflowContextServices(environmentId)
const { mutate: editService, isLoading } = useEditService({ organizationId, projectId, environmentId })
+ const { data: llmProviders = [] } = useLlmProviders({ organizationId, enabled: page === 'ai-configuration' })
const content = PAGE_CONTENT[page]
useDocumentTitle(`${content.title} - Service settings`)
const workflow = service && isAgenticWorkflow(service) ? service : undefined
@@ -142,7 +144,7 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps)
description: workflow.description,
enabled: workflow.enabled,
executionMode: workflow.execution_mode ?? AgenticWorkflowExecutionMode.IN_PLACE,
- modelApiKey: '',
+ llmProviderId: workflow.model.llm_provider_id ?? '',
modelSettings: workflow.model.settings,
agentPrompt: workflow.agent_prompt,
repositories: workflow.project_repositories.map(({ url, branch, git_token_id }) => {
@@ -182,13 +184,15 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps)
const pageValid =
Boolean(values.name.trim()) &&
(page !== 'ai-configuration' ||
- (Boolean(values.agentPrompt.trim()) && agenticWorkflowJsonValidation(values.modelSettings) === true)) &&
+ (Boolean(values.llmProviderId) &&
+ Boolean(values.agentPrompt.trim()) &&
+ agenticWorkflowJsonValidation(values.modelSettings) === true)) &&
(page !== 'connections' || values.repositories.every(isGitRepositoryComplete))
const submit = form.handleSubmit((data) => {
const model: AgenticWorkflowRequest['model'] = {
type: workflow.model.type,
settings: data.modelSettings,
- ...(data.modelApiKey.trim() ? { api_key: data.modelApiKey.trim() } : {}),
+ llm_provider_id: data.llmProviderId,
}
const selectedContextServices = contextServices.filter(({ id }) => data.contextServiceIds.includes(id))
@@ -241,7 +245,12 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps)
diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx
index a9b1a468f44..078c4f74e2f 100644
--- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx
+++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx
@@ -101,7 +101,7 @@ export interface AgenticWorkflowFormData {
mcpJson: string
gitRepositories: AgenticWorkflowGitRepository[]
contextServices: AgenticWorkflowContextService[]
- modelApiKey: string
+ llmProviderId: string
modelSettingsJson: string
whitelistHosts: string
dockerFragment: string
@@ -141,7 +141,7 @@ export function getAgenticWorkflowDefaults(): AgenticWorkflowFormData {
mcpJson: '',
gitRepositories: [],
contextServices: [],
- modelApiKey: '',
+ llmProviderId: '',
modelSettingsJson: DEFAULT_MODEL_SETTINGS,
whitelistHosts: '*',
dockerFragment: '',
diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.spec.ts b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.spec.ts
index eb010eb6640..15f89f61b6b 100644
--- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.spec.ts
+++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.spec.ts
@@ -14,7 +14,7 @@ const values: AgenticWorkflowFormData = {
mcpJson: '',
gitRepositories: [],
contextServices: [],
- modelApiKey: 'api-key',
+ llmProviderId: 'provider-1',
modelSettingsJson: '{}',
whitelistHosts: '*',
dockerFragment: '',
@@ -27,6 +27,14 @@ describe('formatAgenticWorkflowRequest', () => {
expect(formatAgenticWorkflowRequest(values).enabled).toBe(true)
})
+ it('sends the selected LLM provider', () => {
+ expect(formatAgenticWorkflowRequest(values).model).toEqual({
+ type: AgenticWorkflowModelType.CLAUDE,
+ llm_provider_id: 'provider-1',
+ settings: '{}',
+ })
+ })
+
it('sends selected MCP servers and their creation requirements', () => {
expect(formatAgenticWorkflowRequest(values, ['mcp-1']).mcp_servers).toEqual([
{ id: 'mcp-1', required: true },
diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts
index 0227bbac811..e91ebba07c3 100644
--- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts
+++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts
@@ -68,7 +68,7 @@ export function formatAgenticWorkflowRequest(
})),
model: {
type: values.aiModel,
- api_key: values.modelApiKey,
+ llm_provider_id: values.llmProviderId,
settings: values.modelSettingsJson,
},
project_repositories: values.gitRepositories.map((repository) => ({
diff --git a/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx b/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx
index 6422e5f3271..ae675f04ed6 100644
--- a/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx
+++ b/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx
@@ -171,8 +171,9 @@ describe('ServiceNew', () => {
)
- expect(screen.getByRole('heading', { name: 'Agent use cases' })).toBeInTheDocument()
- expect(screen.getByText('BETA')).toBeInTheDocument()
+ const agentUseCasesHeading = screen.getByRole('heading', { name: 'Agent use cases' })
+ expect(agentUseCasesHeading).toBeInTheDocument()
+ expect(within(agentUseCasesHeading.parentElement as HTMLElement).getByText('NEW')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Incident Analyzer with incident.io/i })).toHaveAttribute(
'href',
'/organization/org-1/project/project-1/environment/env-1/service/create/agentic-workflow?template=incident-io-analyzer'
diff --git a/libs/domains/services/feature/src/lib/service-new/service-new.tsx b/libs/domains/services/feature/src/lib/service-new/service-new.tsx
index c25a3e0daae..687b94b79dc 100644
--- a/libs/domains/services/feature/src/lib/service-new/service-new.tsx
+++ b/libs/domains/services/feature/src/lib/service-new/service-new.tsx
@@ -386,7 +386,7 @@ export function ServiceNew({
size="sm"
className="h-4 border-transparent bg-surface-brand-solid px-1 pt-[1px] text-[8px] font-semibold text-neutralInvert"
>
- BETA
+ NEW
diff --git a/package.json b/package.json
index 1f86db38337..b8b6166df49 100644
--- a/package.json
+++ b/package.json
@@ -81,7 +81,7 @@
"mermaid": "11.6.0",
"monaco-editor": "0.53.0",
"posthog-js": "1.345.1",
- "qovery-typescript-axios": "1.1.976",
+ "qovery-typescript-axios": "1.1.979",
"react": "18.3.1",
"react-country-flag": "3.0.2",
"react-datepicker": "4.12.0",
diff --git a/yarn.lock b/yarn.lock
index fbe34380eb9..b95894ed7ef 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6438,7 +6438,7 @@ __metadata:
prettier: 3.2.5
prettier-plugin-tailwindcss: 0.5.14
pretty-quick: 4.0.0
- qovery-typescript-axios: 1.1.976
+ qovery-typescript-axios: 1.1.979
qovery-ws-typescript-axios: 0.1.646
react: 18.3.1
react-country-flag: 3.0.2
@@ -25964,12 +25964,12 @@ __metadata:
languageName: node
linkType: hard
-"qovery-typescript-axios@npm:1.1.976":
- version: 1.1.976
- resolution: "qovery-typescript-axios@npm:1.1.976"
+"qovery-typescript-axios@npm:1.1.979":
+ version: 1.1.979
+ resolution: "qovery-typescript-axios@npm:1.1.979"
dependencies:
axios: 1.18.1
- checksum: 110b239dad9a0b333e2ef20aaacc9a7bec811b95ecffeded1e68adea59553df619a870ede4cca987eb6926d4c0ea64e347b8841c9d4fa388dd4cad438f95e8f4
+ checksum: 412bf2ca0adea62b3e4bd20461052c37f8b179a8cb84d327d4f119180377db7d73a2cc1b8ba7bce92b0ee69ba0afbf96a75cc29d9fcb23f48604719849f2e84e
languageName: node
linkType: hard