diff --git a/apps/console/src/routeTree.gen.ts b/apps/console/src/routeTree.gen.ts index 6f71c6e7ac4..c3d9ea47ef7 100644 --- a/apps/console/src/routeTree.gen.ts +++ b/apps/console/src/routeTree.gen.ts @@ -49,14 +49,17 @@ import { Route as AuthenticatedOrganizationOrganizationIdSettingsBillingDetailsR import { Route as AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRouteImport } from './routes/_authenticated/organization/$organizationId/settings/argocd-integration' import { Route as AuthenticatedOrganizationOrganizationIdSettingsApiTokenRouteImport } from './routes/_authenticated/organization/$organizationId/settings/api-token' import { Route as AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRouteImport } from './routes/_authenticated/organization/$organizationId/settings/ai-copilot' -import { Route as AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteImport } from './routes/_authenticated/organization/$organizationId/settings/agents' import { Route as AuthenticatedOrganizationOrganizationIdClusterNewRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/new' import { Route as AuthenticatedOrganizationOrganizationIdAlertsNotificationChannelRouteImport } from './routes/_authenticated/organization/$organizationId/alerts/notification-channel' import { Route as AuthenticatedOrganizationOrganizationIdAlertsIssuesRouteImport } from './routes/_authenticated/organization/$organizationId/alerts/issues' import { Route as AuthenticatedOrganizationOrganizationIdAlertsAlertRulesRouteImport } from './routes/_authenticated/organization/$organizationId/alerts/alert-rules' +import { Route as AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRouteImport } from './routes/_authenticated/organization/$organizationId/settings/agents/route' import { Route as AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRouteImport } from './routes/_authenticated/organization/$organizationId/settings/roles/index' +import { Route as AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRouteImport } from './routes/_authenticated/organization/$organizationId/settings/agents/index' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/index' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/index' +import { Route as AuthenticatedOrganizationOrganizationIdSettingsAgentsTokensRouteImport } from './routes/_authenticated/organization/$organizationId/settings/agents/tokens' +import { Route as AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRouteImport } from './routes/_authenticated/organization/$organizationId/settings/agents/mcps' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/variables' import { Route as AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRouteImport } from './routes/_authenticated/organization/$organizationId/project/$projectId/overview' import { Route as AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRouteImport } from './routes/_authenticated/organization/$organizationId/cluster/$clusterId/overview' @@ -480,13 +483,6 @@ const AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute = getParentRoute: () => AuthenticatedOrganizationOrganizationIdSettingsRouteRoute, } as any) -const AuthenticatedOrganizationOrganizationIdSettingsAgentsRoute = - AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteImport.update({ - id: '/agents', - path: '/agents', - getParentRoute: () => - AuthenticatedOrganizationOrganizationIdSettingsRouteRoute, - } as any) const AuthenticatedOrganizationOrganizationIdClusterNewRoute = AuthenticatedOrganizationOrganizationIdClusterNewRouteImport.update({ id: '/cluster/new', @@ -516,6 +512,13 @@ const AuthenticatedOrganizationOrganizationIdAlertsAlertRulesRoute = getParentRoute: () => AuthenticatedOrganizationOrganizationIdAlertsRouteRoute, } as any) +const AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRoute = + AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRouteImport.update({ + id: '/agents', + path: '/agents', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdSettingsRouteRoute, + } as any) const AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute = AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRouteImport.update({ id: '/roles/', @@ -523,6 +526,13 @@ const AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute = getParentRoute: () => AuthenticatedOrganizationOrganizationIdSettingsRouteRoute, } as any) +const AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute = + AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRoute, + } as any) const AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRouteImport.update( { @@ -539,6 +549,22 @@ const AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute = getParentRoute: () => AuthenticatedOrganizationOrganizationIdRouteRoute, } as any, ) +const AuthenticatedOrganizationOrganizationIdSettingsAgentsTokensRoute = + AuthenticatedOrganizationOrganizationIdSettingsAgentsTokensRouteImport.update( + { + id: '/tokens', + path: '/tokens', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRoute, + } as any, + ) +const AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRoute = + AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRouteImport.update({ + id: '/mcps', + path: '/mcps', + getParentRoute: () => + AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRoute, + } as any) const AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRoute = AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRouteImport.update( { @@ -1869,11 +1895,11 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/clusters': typeof AuthenticatedOrganizationOrganizationIdClustersRoute '/organization/$organizationId/overview': typeof AuthenticatedOrganizationOrganizationIdOverviewRoute '/organization/$organizationId/': typeof AuthenticatedOrganizationOrganizationIdIndexRoute + '/organization/$organizationId/settings/agents': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRouteWithChildren '/organization/$organizationId/alerts/alert-rules': typeof AuthenticatedOrganizationOrganizationIdAlertsAlertRulesRoute '/organization/$organizationId/alerts/issues': typeof AuthenticatedOrganizationOrganizationIdAlertsIssuesRoute '/organization/$organizationId/alerts/notification-channel': typeof AuthenticatedOrganizationOrganizationIdAlertsNotificationChannelRoute '/organization/$organizationId/cluster/new': typeof AuthenticatedOrganizationOrganizationIdClusterNewRoute - '/organization/$organizationId/settings/agents': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsRoute '/organization/$organizationId/settings/ai-copilot': typeof AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute '/organization/$organizationId/settings/api-token': typeof AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute '/organization/$organizationId/settings/argocd-integration': typeof AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute @@ -1902,8 +1928,11 @@ export interface FileRoutesByFullPath { '/organization/$organizationId/cluster/$clusterId/overview': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute '/organization/$organizationId/project/$projectId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute '/organization/$organizationId/project/$projectId/variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRoute + '/organization/$organizationId/settings/agents/mcps': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRoute + '/organization/$organizationId/settings/agents/tokens': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsTokensRoute '/organization/$organizationId/cluster/$clusterId': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute '/organization/$organizationId/project/$projectId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute + '/organization/$organizationId/settings/agents/': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute '/organization/$organizationId/settings/roles': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute '/organization/$organizationId/cluster/$clusterId/settings/addons': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute @@ -2068,7 +2097,6 @@ export interface FileRoutesByTo { '/organization/$organizationId/alerts/issues': typeof AuthenticatedOrganizationOrganizationIdAlertsIssuesRoute '/organization/$organizationId/alerts/notification-channel': typeof AuthenticatedOrganizationOrganizationIdAlertsNotificationChannelRoute '/organization/$organizationId/cluster/new': typeof AuthenticatedOrganizationOrganizationIdClusterNewRoute - '/organization/$organizationId/settings/agents': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsRoute '/organization/$organizationId/settings/ai-copilot': typeof AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute '/organization/$organizationId/settings/api-token': typeof AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute '/organization/$organizationId/settings/argocd-integration': typeof AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute @@ -2094,8 +2122,11 @@ export interface FileRoutesByTo { '/organization/$organizationId/cluster/$clusterId/overview': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute '/organization/$organizationId/project/$projectId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute '/organization/$organizationId/project/$projectId/variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRoute + '/organization/$organizationId/settings/agents/mcps': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRoute + '/organization/$organizationId/settings/agents/tokens': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsTokensRoute '/organization/$organizationId/cluster/$clusterId': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute '/organization/$organizationId/project/$projectId': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute + '/organization/$organizationId/settings/agents': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute '/organization/$organizationId/settings/roles': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute '/organization/$organizationId/cluster/$clusterId/settings/addons': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute @@ -2247,11 +2278,11 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/clusters': typeof AuthenticatedOrganizationOrganizationIdClustersRoute '/_authenticated/organization/$organizationId/overview': typeof AuthenticatedOrganizationOrganizationIdOverviewRoute '/_authenticated/organization/$organizationId/': typeof AuthenticatedOrganizationOrganizationIdIndexRoute + '/_authenticated/organization/$organizationId/settings/agents': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRouteWithChildren '/_authenticated/organization/$organizationId/alerts/alert-rules': typeof AuthenticatedOrganizationOrganizationIdAlertsAlertRulesRoute '/_authenticated/organization/$organizationId/alerts/issues': typeof AuthenticatedOrganizationOrganizationIdAlertsIssuesRoute '/_authenticated/organization/$organizationId/alerts/notification-channel': typeof AuthenticatedOrganizationOrganizationIdAlertsNotificationChannelRoute '/_authenticated/organization/$organizationId/cluster/new': typeof AuthenticatedOrganizationOrganizationIdClusterNewRoute - '/_authenticated/organization/$organizationId/settings/agents': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsRoute '/_authenticated/organization/$organizationId/settings/ai-copilot': typeof AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute '/_authenticated/organization/$organizationId/settings/api-token': typeof AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute '/_authenticated/organization/$organizationId/settings/argocd-integration': typeof AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute @@ -2280,8 +2311,11 @@ export interface FileRoutesById { '/_authenticated/organization/$organizationId/cluster/$clusterId/overview': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdOverviewRoute '/_authenticated/organization/$organizationId/project/$projectId/overview': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdOverviewRoute '/_authenticated/organization/$organizationId/project/$projectId/variables': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdVariablesRoute + '/_authenticated/organization/$organizationId/settings/agents/mcps': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRoute + '/_authenticated/organization/$organizationId/settings/agents/tokens': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsTokensRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRoute '/_authenticated/organization/$organizationId/project/$projectId/': typeof AuthenticatedOrganizationOrganizationIdProjectProjectIdIndexRoute + '/_authenticated/organization/$organizationId/settings/agents/': typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute '/_authenticated/organization/$organizationId/settings/roles/': typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAddonsRoute '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings': typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdSettingsAdvancedSettingsRoute @@ -2448,11 +2482,11 @@ export interface FileRouteTypes { | '/organization/$organizationId/clusters' | '/organization/$organizationId/overview' | '/organization/$organizationId/' + | '/organization/$organizationId/settings/agents' | '/organization/$organizationId/alerts/alert-rules' | '/organization/$organizationId/alerts/issues' | '/organization/$organizationId/alerts/notification-channel' | '/organization/$organizationId/cluster/new' - | '/organization/$organizationId/settings/agents' | '/organization/$organizationId/settings/ai-copilot' | '/organization/$organizationId/settings/api-token' | '/organization/$organizationId/settings/argocd-integration' @@ -2481,8 +2515,11 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/$clusterId/overview' | '/organization/$organizationId/project/$projectId/overview' | '/organization/$organizationId/project/$projectId/variables' + | '/organization/$organizationId/settings/agents/mcps' + | '/organization/$organizationId/settings/agents/tokens' | '/organization/$organizationId/cluster/$clusterId' | '/organization/$organizationId/project/$projectId' + | '/organization/$organizationId/settings/agents/' | '/organization/$organizationId/settings/roles' | '/organization/$organizationId/cluster/$clusterId/settings/addons' | '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' @@ -2647,7 +2684,6 @@ export interface FileRouteTypes { | '/organization/$organizationId/alerts/issues' | '/organization/$organizationId/alerts/notification-channel' | '/organization/$organizationId/cluster/new' - | '/organization/$organizationId/settings/agents' | '/organization/$organizationId/settings/ai-copilot' | '/organization/$organizationId/settings/api-token' | '/organization/$organizationId/settings/argocd-integration' @@ -2673,8 +2709,11 @@ export interface FileRouteTypes { | '/organization/$organizationId/cluster/$clusterId/overview' | '/organization/$organizationId/project/$projectId/overview' | '/organization/$organizationId/project/$projectId/variables' + | '/organization/$organizationId/settings/agents/mcps' + | '/organization/$organizationId/settings/agents/tokens' | '/organization/$organizationId/cluster/$clusterId' | '/organization/$organizationId/project/$projectId' + | '/organization/$organizationId/settings/agents' | '/organization/$organizationId/settings/roles' | '/organization/$organizationId/cluster/$clusterId/settings/addons' | '/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' @@ -2825,11 +2864,11 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/clusters' | '/_authenticated/organization/$organizationId/overview' | '/_authenticated/organization/$organizationId/' + | '/_authenticated/organization/$organizationId/settings/agents' | '/_authenticated/organization/$organizationId/alerts/alert-rules' | '/_authenticated/organization/$organizationId/alerts/issues' | '/_authenticated/organization/$organizationId/alerts/notification-channel' | '/_authenticated/organization/$organizationId/cluster/new' - | '/_authenticated/organization/$organizationId/settings/agents' | '/_authenticated/organization/$organizationId/settings/ai-copilot' | '/_authenticated/organization/$organizationId/settings/api-token' | '/_authenticated/organization/$organizationId/settings/argocd-integration' @@ -2858,8 +2897,11 @@ export interface FileRouteTypes { | '/_authenticated/organization/$organizationId/cluster/$clusterId/overview' | '/_authenticated/organization/$organizationId/project/$projectId/overview' | '/_authenticated/organization/$organizationId/project/$projectId/variables' + | '/_authenticated/organization/$organizationId/settings/agents/mcps' + | '/_authenticated/organization/$organizationId/settings/agents/tokens' | '/_authenticated/organization/$organizationId/cluster/$clusterId/' | '/_authenticated/organization/$organizationId/project/$projectId/' + | '/_authenticated/organization/$organizationId/settings/agents/' | '/_authenticated/organization/$organizationId/settings/roles/' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/addons' | '/_authenticated/organization/$organizationId/cluster/$clusterId/settings/advanced-settings' @@ -3294,13 +3336,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsRouteRoute } - '/_authenticated/organization/$organizationId/settings/agents': { - id: '/_authenticated/organization/$organizationId/settings/agents' - path: '/agents' - fullPath: '/organization/$organizationId/settings/agents' - preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteImport - parentRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsRouteRoute - } '/_authenticated/organization/$organizationId/cluster/new': { id: '/_authenticated/organization/$organizationId/cluster/new' path: '/cluster/new' @@ -3329,6 +3364,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdAlertsAlertRulesRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdAlertsRouteRoute } + '/_authenticated/organization/$organizationId/settings/agents': { + id: '/_authenticated/organization/$organizationId/settings/agents' + path: '/agents' + fullPath: '/organization/$organizationId/settings/agents' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsRouteRoute + } '/_authenticated/organization/$organizationId/settings/roles/': { id: '/_authenticated/organization/$organizationId/settings/roles/' path: '/roles' @@ -3336,6 +3378,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsRolesIndexRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsRouteRoute } + '/_authenticated/organization/$organizationId/settings/agents/': { + id: '/_authenticated/organization/$organizationId/settings/agents/' + path: '/' + fullPath: '/organization/$organizationId/settings/agents/' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/': { id: '/_authenticated/organization/$organizationId/project/$projectId/' path: '/project/$projectId' @@ -3350,6 +3399,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdClusterClusterIdIndexRouteImport parentRoute: typeof AuthenticatedOrganizationOrganizationIdRouteRoute } + '/_authenticated/organization/$organizationId/settings/agents/tokens': { + id: '/_authenticated/organization/$organizationId/settings/agents/tokens' + path: '/tokens' + fullPath: '/organization/$organizationId/settings/agents/tokens' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsTokensRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRoute + } + '/_authenticated/organization/$organizationId/settings/agents/mcps': { + id: '/_authenticated/organization/$organizationId/settings/agents/mcps' + path: '/mcps' + fullPath: '/organization/$organizationId/settings/agents/mcps' + preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRouteImport + parentRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRoute + } '/_authenticated/organization/$organizationId/project/$projectId/variables': { id: '/_authenticated/organization/$organizationId/project/$projectId/variables' path: '/project/$projectId/variables' @@ -4427,8 +4490,29 @@ const AuthenticatedOrganizationOrganizationIdAlertsRouteRouteWithChildren = AuthenticatedOrganizationOrganizationIdAlertsRouteRouteChildren, ) +interface AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRouteChildren { + AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRoute + AuthenticatedOrganizationOrganizationIdSettingsAgentsTokensRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsTokensRoute + AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute +} + +const AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRouteChildren: AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRouteChildren = + { + AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRoute: + AuthenticatedOrganizationOrganizationIdSettingsAgentsMcpsRoute, + AuthenticatedOrganizationOrganizationIdSettingsAgentsTokensRoute: + AuthenticatedOrganizationOrganizationIdSettingsAgentsTokensRoute, + AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute: + AuthenticatedOrganizationOrganizationIdSettingsAgentsIndexRoute, + } + +const AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRouteWithChildren = + AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRoute._addFileChildren( + AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRouteChildren, + ) + interface AuthenticatedOrganizationOrganizationIdSettingsRouteRouteChildren { - AuthenticatedOrganizationOrganizationIdSettingsAgentsRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsRoute + AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRouteWithChildren AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsArgocdIntegrationRoute @@ -4453,8 +4537,8 @@ interface AuthenticatedOrganizationOrganizationIdSettingsRouteRouteChildren { const AuthenticatedOrganizationOrganizationIdSettingsRouteRouteChildren: AuthenticatedOrganizationOrganizationIdSettingsRouteRouteChildren = { - AuthenticatedOrganizationOrganizationIdSettingsAgentsRoute: - AuthenticatedOrganizationOrganizationIdSettingsAgentsRoute, + AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRoute: + AuthenticatedOrganizationOrganizationIdSettingsAgentsRouteRouteWithChildren, AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute: AuthenticatedOrganizationOrganizationIdSettingsAiCopilotRoute, AuthenticatedOrganizationOrganizationIdSettingsApiTokenRoute: diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents/index.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents/index.tsx new file mode 100644 index 00000000000..7457f17ba12 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents/index.tsx @@ -0,0 +1,13 @@ +import { Navigate, createFileRoute, useParams } from '@tanstack/react-router' + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/settings/agents/')({ + component: RouteComponent, +}) + +function RouteComponent() { + const { organizationId } = useParams({ strict: false }) + + if (!organizationId) return null + + return +} diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents/mcps.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents/mcps.tsx new file mode 100644 index 00000000000..a29f8e85503 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents/mcps.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SettingsAgentMcps } from '@qovery/domains/organizations/feature' + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/settings/agents/mcps')({ + component: SettingsAgentMcps, +}) diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents/route.tsx similarity index 78% rename from apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents.tsx rename to apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents/route.tsx index 264e8729ec5..80195c4ed2a 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents/route.tsx @@ -1,7 +1,6 @@ -import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' +import { Outlet, createFileRoute, useNavigate, useParams } from '@tanstack/react-router' import { useFeatureFlagEnabled } from 'posthog-js/react' import { useEffect } from 'react' -import { SettingsAgentPersonalization } from '@qovery/domains/organizations/feature' export const Route = createFileRoute('/_authenticated/organization/$organizationId/settings/agents')({ component: RouteComponent, @@ -24,5 +23,5 @@ function RouteComponent() { if (!isAgenticWorkflowEnabled) return null - return + return } diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents/tokens.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents/tokens.tsx new file mode 100644 index 00000000000..514c7527b35 --- /dev/null +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/settings/agents/tokens.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { SettingsAgentTokens } from '@qovery/domains/organizations/feature' + +export const Route = createFileRoute('/_authenticated/organization/$organizationId/settings/agents/tokens')({ + component: SettingsAgentTokens, +}) diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/settings/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/settings/route.tsx index 600253b5970..f87e765ba56 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/settings/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/settings/route.tsx @@ -104,9 +104,17 @@ function RouteComponent() { icon: 'sparkles' as const, children: [ { title: 'Copilot', to: `${pathSettings}/ai-copilot` }, - ...(isAgenticWorkflowEnabled ? [{ title: 'Agents', to: `${pathSettings}/agents` }] : []), { title: 'Skills', to: `${pathSettings}/skills` }, - { title: 'MCP server', to: `${pathSettings}/mcp-server` }, + ], + } + + const agentsLink = { + type: 'group', + title: 'Agent', + icon: 'robot' as const, + children: [ + { title: 'Token', to: `${pathSettings}/agents/tokens` }, + { title: 'MCP', to: `${pathSettings}/agents/mcps` }, ], } @@ -129,6 +137,7 @@ function RouteComponent() { webhookLink, apiTokenLink, aiLink, + ...(isAgenticWorkflowEnabled ? [agentsLink] : []), ...(isOrganizationAdmin ? [dangerZoneLink] : []), ] diff --git a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts index 5806761a976..aa8b642fcab 100644 --- a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts +++ b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts @@ -1,4 +1,11 @@ -import { MCPServersApi, OrganizationMainCallsApi, OrganizationOnboardingStatusEnum } from 'qovery-typescript-axios' +import { + LLMProvidersApi, + LlmProviderScope, + LlmProviderType, + MCPServersApi, + OrganizationMainCallsApi, + OrganizationOnboardingStatusEnum, +} from 'qovery-typescript-axios' import { mutations, organizations } from './domains-organizations-data-access' describe('organizations.onboarding', () => { @@ -104,3 +111,60 @@ describe('MCP server mutations', () => { expect(MCPServersApi.prototype.deleteMcpServer).toHaveBeenCalledWith('mcp-1') }) }) + +describe('organizations.llmProviders', () => { + it('should return organization LLM providers', async () => { + const mockData = [{ id: 'provider-1', name: 'Claude' }] + jest + .spyOn(LLMProvidersApi.prototype, 'listLlmProviders') + .mockResolvedValue({ data: { results: mockData } } as never) + + const query = organizations.llmProviders({ organizationId: 'org-1' }) + const result = await query.queryFn({} as never) + + expect(LLMProvidersApi.prototype.listLlmProviders).toHaveBeenCalledWith('org-1') + expect(result).toEqual(mockData) + expect(query.queryKey).toContain('org-1') + }) +}) + +describe('LLM provider mutations', () => { + const llmProviderRequest = { + name: 'Claude', + type: LlmProviderType.CLAUDE, + credential: 'secret', + scope: LlmProviderScope.USER, + } + + it('should create an LLM provider', async () => { + const mockData = { id: 'provider-1', ...llmProviderRequest } + jest.spyOn(LLMProvidersApi.prototype, 'createLlmProvider').mockResolvedValue({ data: mockData } as never) + + const result = await mutations.createLlmProvider({ organizationId: 'org-1', llmProviderRequest }) + + expect(LLMProvidersApi.prototype.createLlmProvider).toHaveBeenCalledWith('org-1', llmProviderRequest) + expect(result).toEqual(mockData) + }) + + it('should edit an LLM provider', async () => { + const mockData = { id: 'provider-1', ...llmProviderRequest } + jest.spyOn(LLMProvidersApi.prototype, 'editLlmProvider').mockResolvedValue({ data: mockData } as never) + + const result = await mutations.editLlmProvider({ + organizationId: 'org-1', + llmProviderId: 'provider-1', + llmProviderRequest, + }) + + expect(LLMProvidersApi.prototype.editLlmProvider).toHaveBeenCalledWith('provider-1', llmProviderRequest) + expect(result).toEqual(mockData) + }) + + it('should delete an LLM provider', async () => { + jest.spyOn(LLMProvidersApi.prototype, 'deleteLlmProvider').mockResolvedValue({ data: undefined } as never) + + await mutations.deleteLlmProvider({ organizationId: 'org-1', llmProviderId: 'provider-1' }) + + expect(LLMProvidersApi.prototype.deleteLlmProvider).toHaveBeenCalledWith('provider-1') + }) +}) diff --git a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts index 7cce133a153..4ff5b72b9b1 100644 --- a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts +++ b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts @@ -14,6 +14,8 @@ import { HelmRepositoriesApi, type HelmRepositoryRequest, type InviteMemberRequest, + LLMProvidersApi, + type LlmProviderRequest, MCPServersApi, type McpServerRequest, type MemberRoleUpdateRequest, @@ -62,6 +64,7 @@ const billingApi = new BillingApi() const customRolesApi = new OrganizationCustomRoleApi() const membersApi = new MembersApi() const mcpServersApi = new MCPServersApi() +const llmProvidersApi = new LLMProvidersApi() const githubAppApi = new GithubAppApi() const argoCdApi = new ArgoCDApi() @@ -122,6 +125,13 @@ export const organizations = createQueryKeys('organizations', { return response.data.results }, }), + llmProviders: ({ organizationId }: { organizationId: string }) => ({ + queryKey: [organizationId], + async queryFn() { + const response = await llmProvidersApi.listLlmProviders(organizationId) + return response.data.results + }, + }), containerRegistry: ({ organizationId, containerRegistryId, @@ -674,6 +684,31 @@ export const mutations = { const response = await mcpServersApi.deleteMcpServer(mcpServerId) return response.data }, + async createLlmProvider({ + organizationId, + llmProviderRequest, + }: { + organizationId: string + llmProviderRequest: LlmProviderRequest + }) { + const response = await llmProvidersApi.createLlmProvider(organizationId, llmProviderRequest) + return response.data + }, + async editLlmProvider({ + llmProviderId, + llmProviderRequest, + }: { + organizationId: string + llmProviderId: string + llmProviderRequest: LlmProviderRequest + }) { + const response = await llmProvidersApi.editLlmProvider(llmProviderId, llmProviderRequest) + return response.data + }, + async deleteLlmProvider({ llmProviderId }: { organizationId: string; llmProviderId: string }) { + const response = await llmProvidersApi.deleteLlmProvider(llmProviderId) + return response.data + }, async deleteGitToken({ organizationId, gitTokenId }: { organizationId: string; gitTokenId: string }) { const response = await organizationApi.deleteGitToken(organizationId, gitTokenId) return response.data diff --git a/libs/domains/organizations/feature/src/index.ts b/libs/domains/organizations/feature/src/index.ts index 651d5151a5d..dde8010217b 100644 --- a/libs/domains/organizations/feature/src/index.ts +++ b/libs/domains/organizations/feature/src/index.ts @@ -102,6 +102,10 @@ export * from './lib/hooks/use-create-mcp-server/use-create-mcp-server' export * from './lib/hooks/use-create-qovery-mcp-server/use-create-qovery-mcp-server' export * from './lib/hooks/use-edit-mcp-server/use-edit-mcp-server' export * from './lib/hooks/use-delete-mcp-server/use-delete-mcp-server' +export * from './lib/hooks/use-llm-providers/use-llm-providers' +export * from './lib/hooks/use-create-llm-provider/use-create-llm-provider' +export * from './lib/hooks/use-edit-llm-provider/use-edit-llm-provider' +export * from './lib/hooks/use-delete-llm-provider/use-delete-llm-provider' export * from './lib/invoice-banner/invoice-banner' export * from './lib/settings-general/settings-general' export * from './lib/settings-labels-annotations/settings-labels-annotations' @@ -119,6 +123,7 @@ export * from './lib/settings-ai-skills/settings-ai-skills' export * from './lib/settings-agent-personalization/settings-agent-personalization' export * from './lib/mcp-server-create-edit-modal/mcp-server-create-edit-modal' export * from './lib/mcp-server-setting/mcp-server-setting' +export * from './lib/llm-provider-setting/llm-provider-setting' export * from './lib/settings-argocd-integration/settings-argocd-integration' export * from './lib/settings-danger-zone/settings-danger-zone' export * from './lib/settings-billing-details/settings-billing-details' diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-create-llm-provider/use-create-llm-provider.ts b/libs/domains/organizations/feature/src/lib/hooks/use-create-llm-provider/use-create-llm-provider.ts new file mode 100644 index 00000000000..9b0114bbdeb --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/hooks/use-create-llm-provider/use-create-llm-provider.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/organizations/data-access' +import { queries } from '@qovery/state/util-queries' + +export function useCreateLlmProvider() { + const queryClient = useQueryClient() + + return useMutation(mutations.createLlmProvider, { + onSuccess(_, { organizationId }) { + queryClient.invalidateQueries({ queryKey: queries.organizations.llmProviders({ organizationId }).queryKey }) + }, + meta: { + notifyOnSuccess: { title: 'Your token has been created' }, + notifyOnError: true, + }, + }) +} diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-delete-llm-provider/use-delete-llm-provider.ts b/libs/domains/organizations/feature/src/lib/hooks/use-delete-llm-provider/use-delete-llm-provider.ts new file mode 100644 index 00000000000..4f8ae76a8c3 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/hooks/use-delete-llm-provider/use-delete-llm-provider.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/organizations/data-access' +import { queries } from '@qovery/state/util-queries' + +export function useDeleteLlmProvider() { + const queryClient = useQueryClient() + + return useMutation(mutations.deleteLlmProvider, { + onSuccess(_, { organizationId }) { + queryClient.invalidateQueries({ queryKey: queries.organizations.llmProviders({ organizationId }).queryKey }) + }, + meta: { + notifyOnSuccess: { title: 'Your token has been deleted' }, + notifyOnError: true, + }, + }) +} diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-edit-llm-provider/use-edit-llm-provider.ts b/libs/domains/organizations/feature/src/lib/hooks/use-edit-llm-provider/use-edit-llm-provider.ts new file mode 100644 index 00000000000..e2ada667af4 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/hooks/use-edit-llm-provider/use-edit-llm-provider.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/organizations/data-access' +import { queries } from '@qovery/state/util-queries' + +export function useEditLlmProvider() { + const queryClient = useQueryClient() + + return useMutation(mutations.editLlmProvider, { + onSuccess(_, { organizationId }) { + queryClient.invalidateQueries({ queryKey: queries.organizations.llmProviders({ organizationId }).queryKey }) + }, + meta: { + notifyOnSuccess: { title: 'Your token has been updated' }, + notifyOnError: true, + }, + }) +} diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-llm-providers/use-llm-providers.ts b/libs/domains/organizations/feature/src/lib/hooks/use-llm-providers/use-llm-providers.ts new file mode 100644 index 00000000000..e7228d3b751 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/hooks/use-llm-providers/use-llm-providers.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query' +import { queries } from '@qovery/state/util-queries' + +export interface UseLlmProvidersProps { + organizationId: string + suspense?: boolean + enabled?: boolean +} + +export function useLlmProviders({ organizationId, suspense = false, enabled = true }: UseLlmProvidersProps) { + return useQuery({ + ...queries.organizations.llmProviders({ organizationId }), + suspense, + enabled, + }) +} 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 new file mode 100644 index 00000000000..abed1f464c6 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx @@ -0,0 +1,111 @@ +import { LlmProviderScope, LlmProviderType } from 'qovery-typescript-axios' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import * as useCreateLlmProviderHook from '../hooks/use-create-llm-provider/use-create-llm-provider' +import * as useEditLlmProviderHook from '../hooks/use-edit-llm-provider/use-edit-llm-provider' +import { LlmProviderCreateEditModal } from './llm-provider-create-edit-modal' + +const useCreateLlmProviderMock = jest.spyOn(useCreateLlmProviderHook, 'useCreateLlmProvider') as jest.Mock +const useEditLlmProviderMock = jest.spyOn(useEditLlmProviderHook, 'useEditLlmProvider') as jest.Mock +const createLlmProvider = jest.fn() +const editLlmProvider = jest.fn() + +jest.mock('@tanstack/react-router', () => ({ + ...jest.requireActual('@tanstack/react-router'), + useParams: () => ({ organizationId: 'org-1' }), +})) + +describe('LlmProviderCreateEditModal', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.clearAllMocks() + createLlmProvider.mockResolvedValue({ id: 'provider-1' }) + editLlmProvider.mockResolvedValue({ id: 'provider-1' }) + useCreateLlmProviderMock.mockReturnValue({ mutateAsync: createLlmProvider, isLoading: false }) + useEditLlmProviderMock.mockReturnValue({ mutateAsync: editLlmProvider, isLoading: false }) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should create a personal Claude token', async () => { + const onClose = jest.fn() + const { userEvent } = renderWithProviders() + + await userEvent.type(screen.getByLabelText('Name'), ' My Claude ') + await userEvent.type(screen.getByLabelText('Token'), ' sk-ant-secret ') + await userEvent.click(screen.getByRole('button', { name: 'Add token' })) + + await waitFor(() => + expect(createLlmProvider).toHaveBeenCalledWith({ + organizationId: 'org-1', + llmProviderRequest: { + name: 'My Claude', + description: undefined, + type: LlmProviderType.CLAUDE, + credential: 'sk-ant-secret', + scope: LlmProviderScope.USER, + }, + }) + ) + expect(onClose).toHaveBeenCalledWith({ id: 'provider-1' }) + }) + + it('should reject a whitespace-only token', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.type(screen.getByLabelText('Name'), 'My Claude') + await userEvent.type(screen.getByLabelText('Token'), ' ') + await userEvent.click(screen.getByRole('button', { name: 'Add token' })) + + expect(await screen.findByText('Please enter a token.')).toBeInTheDocument() + expect(createLlmProvider).not.toHaveBeenCalled() + }) + + it('should keep the stored token when editing with a blank value', async () => { + const { userEvent } = renderWithProviders( + + ) + + expect(screen.queryByLabelText('Scope')).not.toBeInTheDocument() + expect(screen.getByText('Leave blank to keep the current token.')).toBeInTheDocument() + const saveButton = screen.getByRole('button', { name: 'Save token' }) + await waitFor(() => expect(saveButton).toBeEnabled()) + await userEvent.click(saveButton) + + await waitFor(() => + expect(editLlmProvider).toHaveBeenCalledWith({ + organizationId: 'org-1', + llmProviderId: 'provider-1', + llmProviderRequest: { + name: 'Claude', + description: undefined, + type: LlmProviderType.CLAUDE, + credential: undefined, + scope: undefined, + }, + }) + ) + }) + + it('should select Claude by default and keep the provider locked', () => { + const { container } = renderWithProviders() + + expect(container.querySelector('img[src="/assets/ai-tools/claude.svg"]')).toBeInTheDocument() + expect(screen.getByLabelText('Provider')).toBeDisabled() + expect(screen.getByText('Anthropic Claude')).toBeInTheDocument() + expect(screen.queryByText('AWS Bedrock')).not.toBeInTheDocument() + }) +}) 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 new file mode 100644 index 00000000000..61c55f8acd3 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.tsx @@ -0,0 +1,184 @@ +import { useParams } from '@tanstack/react-router' +import { + type LlmProviderRequest, + type LlmProviderResponse, + LlmProviderScope, + LlmProviderType, +} from 'qovery-typescript-axios' +import { Controller, FormProvider, useForm } from 'react-hook-form' +import { InputSelect, InputText, InputTextArea, ModalCrud, useModal } from '@qovery/shared/ui' +import { useCreateLlmProvider } from '../hooks/use-create-llm-provider/use-create-llm-provider' +import { useEditLlmProvider } from '../hooks/use-edit-llm-provider/use-edit-llm-provider' + +interface LlmProviderFormValues { + name: string + description: string + type: LlmProviderType + credential: string + scope: LlmProviderScope +} + +export interface LlmProviderCreateEditModalProps { + onClose: (response?: LlmProviderResponse) => void + llmProvider?: LlmProviderResponse +} + +const PROVIDER_OPTIONS = [ + { + label: ( + + + Anthropic Claude + + ), + value: LlmProviderType.CLAUDE, + }, +] + +const SCOPE_OPTIONS = [ + { label: 'Personal', value: LlmProviderScope.USER }, + { label: 'Organization', value: LlmProviderScope.ORGANIZATION }, +] + +export function LlmProviderCreateEditModal({ onClose, llmProvider }: LlmProviderCreateEditModalProps) { + const { organizationId = '' } = useParams({ strict: false }) + const isEdit = llmProvider !== undefined + const { enableAlertClickOutside } = useModal() + const methods = useForm({ + mode: 'onChange', + defaultValues: { + name: llmProvider?.name ?? '', + description: llmProvider?.description ?? '', + type: llmProvider?.type ?? LlmProviderType.CLAUDE, + credential: '', + scope: llmProvider?.scope ?? LlmProviderScope.USER, + }, + }) + methods.watch(() => enableAlertClickOutside(methods.formState.isDirty)) + + const { mutateAsync: createLlmProvider, isLoading: isCreating } = useCreateLlmProvider() + const { mutateAsync: editLlmProvider, isLoading: isEditing } = useEditLlmProvider() + + const onSubmit = methods.handleSubmit(async (data) => { + const credential = data.credential.trim() + const llmProviderRequest: LlmProviderRequest = { + name: data.name.trim(), + description: data.description.trim() || undefined, + type: data.type, + credential: credential || undefined, + scope: isEdit ? undefined : data.scope, + } + + try { + const response = isEdit + ? await editLlmProvider({ organizationId, llmProviderId: llmProvider.id, llmProviderRequest }) + : await createLlmProvider({ organizationId, llmProviderRequest }) + onClose(response) + } catch (error) { + console.error(error) + } + }) + + return ( + + +
+ {!isEdit ? ( + ( + + )} + /> + ) : null} + Boolean(value.trim()) || 'Please enter a token name.', + }} + render={({ field, fieldState: { error } }) => ( + + )} + /> + ( + + )} + /> + Boolean(value.trim()) || 'Please enter a token.', + } + } + render={({ field, fieldState: { error } }) => ( + + )} + /> + ( + + )} + /> +
+
+
+ ) +} diff --git a/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.spec.tsx b/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.spec.tsx new file mode 100644 index 00000000000..71276a79e66 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.spec.tsx @@ -0,0 +1,54 @@ +import { act } from '@testing-library/react' +import { type LlmProviderResponse, LlmProviderScope, LlmProviderType } from 'qovery-typescript-axios' +import { type ReactElement } from 'react' +import selectEvent from 'react-select-event' +import * as sharedUi from '@qovery/shared/ui' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { type LlmProviderCreateEditModalProps } from '../llm-provider-create-edit-modal/llm-provider-create-edit-modal' +import { LlmProviderSetting } from './llm-provider-setting' + +const useModalMock = jest.spyOn(sharedUi, 'useModal') as jest.Mock +const openModal = jest.fn() +const closeModal = jest.fn() + +jest.mock('@tanstack/react-router', () => ({ + useParams: () => ({ organizationId: 'organization-1' }), +})) + +const llmProvider: LlmProviderResponse = { + id: 'provider-1', + name: 'Claude token', + description: 'Personal token', + type: LlmProviderType.CLAUDE, + has_credential: true, + scope: LlmProviderScope.USER, + created_at: '2026-09-16T10:00:00Z', + updated_at: '2026-09-16T10:00:00Z', +} + +describe('LlmProviderSetting', () => { + beforeEach(() => { + jest.clearAllMocks() + useModalMock.mockReturnValue({ openModal, closeModal }) + }) + + it('should create a token and select it', () => { + const onChange = jest.fn() + renderWithProviders() + + expect(screen.getByRole('link', { name: 'Agents → Tokens' })).toHaveAttribute( + 'href', + '/organization/organization-1/settings/agents/tokens' + ) + selectEvent.openMenu(screen.getByLabelText('Token')) + screen.getByTestId('input-menu-list-button').click() + + expect(openModal).toHaveBeenCalledWith(expect.objectContaining({ options: { fakeModal: true, width: 680 } })) + + const modal = openModal.mock.calls[0][0].content as ReactElement + act(() => modal.props.onClose(llmProvider)) + + expect(onChange).toHaveBeenCalledWith('provider-1') + expect(closeModal).toHaveBeenCalled() + }) +}) diff --git a/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.tsx b/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.tsx new file mode 100644 index 00000000000..7d05f6324b0 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.tsx @@ -0,0 +1,84 @@ +import { useParams } from '@tanstack/react-router' +import { type LlmProviderResponse } from 'qovery-typescript-axios' +import { useState } from 'react' +import { InputSelect, useModal } from '@qovery/shared/ui' +import { LlmProviderCreateEditModal } from '../llm-provider-create-edit-modal/llm-provider-create-edit-modal' + +export interface LlmProviderSettingProps { + error?: string + isLoading?: boolean + llmProviders: LlmProviderResponse[] + value: string + onChange: (value: string) => void +} + +export function LlmProviderSetting({ error, isLoading, llmProviders, value, onChange }: LlmProviderSettingProps) { + const { organizationId = '' } = useParams({ strict: false }) + const { openModal, closeModal } = useModal() + const [createdLlmProvider, setCreatedLlmProvider] = useState() + const availableLlmProviders = + createdLlmProvider && !llmProviders.some(({ id }) => id === createdLlmProvider.id) + ? [...llmProviders, createdLlmProvider] + : llmProviders + + const openCreateModal = () => { + openModal({ + content: ( + { + if (response) { + setCreatedLlmProvider(response) + onChange(response.id) + } + closeModal() + }} + /> + ), + options: { + fakeModal: true, + width: 680, + }, + }) + } + + return ( + ({ + 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)
    {page === 'general' ? : null} {page === 'ai-configuration' ? ( - + type === LlmProviderType.CLAUDE && has_credential + )} + /> ) : null} {page === 'connections' ? ( ({ GitBranchSettings: () =>
    Git branch
    , GitProviderSetting: () =>
    Git provider
    , GitRepositorySetting: () =>
    Git repository
    , + LlmProviderSetting: ({ onChange }: { onChange: (value: string) => void }) => ( + + ), McpServerCreateEditModal: () =>
    Create MCP server
    , McpServerSetting: () =>
    Organization MCP connectors
    , useCreateQoveryMcpServer: () => ({ isLoading: mockCreateQoveryMcpServerLoading, mutateAsync: mockCreateQoveryMcpServer, }), + useLlmProviders: () => ({ data: [], isLoading: false }), useMcpServers: () => ({ data: mockMcpServers, isError: mockMcpServersError, @@ -133,7 +139,7 @@ function renderConfiguration({ const validSeed: Partial = { name: 'review-agent', agentPrompt: 'Review incoming payloads.', - modelApiKey: 'sk-ant-test', + llmProviderId: 'provider-1', automations: [{ id: 'automation-1', triggers: [{ id: 'webhook-1', type: 'webhook' }], outputs: [] }], } @@ -294,7 +300,8 @@ describe('AgenticWorkflowConfiguration', () => { await userEvent.click(screen.getByRole('button', { name: 'Anthropic' })) expect(screen.getByRole('heading', { name: 'Configure provider' })).toBeInTheDocument() - expect(screen.getByLabelText('API key')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Select stored token' })).toBeInTheDocument() + expect(screen.queryByLabelText('API key')).not.toBeInTheDocument() expect(screen.getByText('Cloud settings JSON')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Save provider' })) @@ -639,7 +646,7 @@ describe('AgenticWorkflowConfiguration', () => { await userEvent.type(screen.getByRole('textbox', { name: /Instructions/ }), 'Review incoming payloads.') await userEvent.click(screen.getByRole('button', { name: 'Anthropic' })) - await userEvent.type(screen.getByLabelText('API key'), 'sk-ant-test') + await userEvent.click(screen.getByRole('button', { name: 'Select stored token' })) await userEvent.click(screen.getByRole('button', { name: 'Save provider' })) expect(createButton).toBeEnabled() @@ -731,7 +738,7 @@ describe('AgenticWorkflowConfiguration', () => { await userEvent.type(screen.getByRole('textbox', { name: 'Name' }), 'review-agent') await userEvent.type(screen.getByRole('textbox', { name: 'Instructions' }), 'Review incoming payloads.') await userEvent.click(screen.getByRole('button', { name: 'Anthropic' })) - await userEvent.type(screen.getByLabelText('API key'), 'sk-ant-test') + await userEvent.click(screen.getByRole('button', { name: 'Select stored token' })) await userEvent.click(screen.getByRole('button', { name: 'Save provider' })) await userEvent.click(screen.getByRole('button', { name: 'Add trigger' })) await userEvent.click(screen.getByRole('button', { name: 'Add' })) 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 8a10aed70fa..60598cb620d 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 @@ -1,10 +1,15 @@ import { useNavigate, useParams } from '@tanstack/react-router' import clsx from 'clsx' import posthog from 'posthog-js' -import { APIVariableScopeEnum, type McpServerResponse } from 'qovery-typescript-axios' +import { APIVariableScopeEnum, LlmProviderType, type McpServerResponse } from 'qovery-typescript-axios' import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { Controller, FormProvider, useFieldArray } from 'react-hook-form' -import { useCreateQoveryMcpServer, useMcpServers } from '@qovery/domains/organizations/feature' +import { + LlmProviderSetting, + useCreateQoveryMcpServer, + useLlmProviders, + useMcpServers, +} from '@qovery/domains/organizations/feature' import { VariableRow, useImportVariables } from '@qovery/domains/variables/feature' import { IconEnum } from '@qovery/shared/enums' import { type VariableData } from '@qovery/shared/interfaces' @@ -15,6 +20,7 @@ import { DropdownMenu, Heading, Icon, + InputSelect, InputText, InputTextArea, Modal, @@ -282,6 +288,7 @@ export function AgenticWorkflowConfiguration() { isLoading: areMcpServersLoading, refetch: refetchMcpServers, } = useMcpServers({ organizationId }) + const { data: llmProviders = [], isLoading: areLlmProvidersLoading } = useLlmProviders({ organizationId }) const { data: contextServices = [], isLoading: areContextServicesLoading } = useAgenticWorkflowContextServices(environmentId) const navigate = useNavigate() @@ -314,7 +321,6 @@ export function AgenticWorkflowConfiguration() { const [dockerModalOpen, setDockerModalOpen] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [showValidationErrors, setShowValidationErrors] = useState(false) - const modelApiKeyInputRef = useRef(null) const headerRef = useRef(null) const promptEditorRef = useRef(null) const createdServiceIdRef = useRef() @@ -329,8 +335,12 @@ export function AgenticWorkflowConfiguration() { const variablesValid = areVariablesValid(variableValues) const showNameError = (showValidationErrors || Boolean(dirtyFields.name)) && !values.name.trim() const showPromptError = (showValidationErrors || Boolean(dirtyFields.agentPrompt)) && !values.agentPrompt.trim() - const showModelApiKeyError = (showValidationErrors || Boolean(dirtyFields.modelApiKey)) && !values.modelApiKey.trim() - const providerConfigurationInvalid = !values.modelApiKey.trim() || Boolean(modelSettingsJsonError) + const hasModelCredential = Boolean(values.llmProviderId) + const showLlmProviderError = (showValidationErrors || Boolean(dirtyFields.llmProviderId)) && !hasModelCredential + const providerConfigurationInvalid = !hasModelCredential || Boolean(modelSettingsJsonError) + const availableLlmProviders = llmProviders.filter( + ({ type, has_credential }) => type === LlmProviderType.CLAUDE && has_credential + ) const settingsGroupsInvalid: Record = { general: false, resources: false, @@ -506,9 +516,6 @@ export function AgenticWorkflowConfiguration() { if (providerConfigurationInvalid) { setProviderModalOpen(true) - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => modelApiKeyInputRef.current?.focus()) - }) return false } @@ -925,11 +932,11 @@ export function AgenticWorkflowConfiguration() { Anthropic - {!values.modelApiKey.trim() ? ( + {!hasModelCredential ? ( - API key required + Token required ) : null} @@ -1045,7 +1052,6 @@ export function AgenticWorkflowConfiguration() {
    variable.variable ?? '').filter(Boolean)} @@ -1072,16 +1078,14 @@ export function AgenticWorkflowConfiguration() { setOpen={setProviderModalOpen} > ( - )} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-prompt-editor.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-prompt-editor.tsx index ad3e1a77a25..66c7003ddb9 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-prompt-editor.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-prompt-editor.tsx @@ -1,3 +1,4 @@ +import { useParams } from '@tanstack/react-router' import { forwardRef, useImperativeHandle, useRef } from 'react' import { useVariables } from '@qovery/domains/variables/feature' import { Icon, PromptEditor, type PromptEditorHandle, Tooltip } from '@qovery/shared/ui' @@ -8,7 +9,6 @@ export interface AgenticWorkflowPromptEditorHandle { export interface AgenticWorkflowPromptEditorProps { compact?: boolean - environmentId: string onPromptChange: (value: string) => void prompt: string promptError?: string @@ -18,10 +18,8 @@ export interface AgenticWorkflowPromptEditorProps { export const AgenticWorkflowPromptEditor = forwardRef< AgenticWorkflowPromptEditorHandle, AgenticWorkflowPromptEditorProps ->(function AgenticWorkflowPromptEditor( - { compact = false, environmentId, onPromptChange, prompt, promptError, variableKeys }, - ref -) { +>(function AgenticWorkflowPromptEditor({ compact = false, onPromptChange, prompt, promptError, variableKeys }, ref) { + const { environmentId = '' } = useParams({ strict: false }) const promptRef = useRef(null) const { data: environmentVariables = [] } = useVariables({ parentId: environmentId, scope: 'ENVIRONMENT' }) const suggestions = Array.from(new Set([...variableKeys, ...environmentVariables.map(({ key }) => key)])).map( diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx index 7a99d3213aa..aea06d09bd9 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx @@ -24,8 +24,9 @@ describe('QoveryServiceContextModal', () => { ) await userEvent.click(screen.getByRole('checkbox', { name: 'api' })) + expect(screen.getByRole('button', { name: 'Unselect all' })).toBeInTheDocument() await userEvent.click(screen.getByRole('checkbox', { name: 'postgres' })) - expect(screen.queryByRole('button', { name: 'Select all' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Unselect all' })).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Confirm' })) expect(onSave).toHaveBeenCalledWith(services) @@ -43,7 +44,7 @@ describe('QoveryServiceContextModal', () => { ) expect(screen.getByRole('checkbox', { name: 'api' })).toBeChecked() - expect(screen.getByRole('button', { name: 'Select all' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Unselect all' })).toBeInTheDocument() }) it('keeps Select all available when only a stale service ID is selected', () => { @@ -60,12 +61,16 @@ describe('QoveryServiceContextModal', () => { expect(screen.getByRole('button', { name: 'Select all' })).toBeInTheDocument() }) - it('removes the hidden Select all action from keyboard interaction', () => { - renderWithProviders( + it('unselects all services when at least one service is selected', async () => { + const { userEvent } = renderWithProviders( ) - expect(screen.getByText('Select all').closest('button')).toBeDisabled() + await userEvent.click(screen.getByRole('button', { name: 'Unselect all' })) + + expect(screen.getByRole('checkbox', { name: 'api' })).not.toBeChecked() + expect(screen.getByRole('checkbox', { name: 'postgres' })).not.toBeChecked() + expect(screen.getByRole('button', { name: 'Select all' })).toBeInTheDocument() }) it('prevents closing while services are being saved', async () => { @@ -84,7 +89,7 @@ describe('QoveryServiceContextModal', () => { await userEvent.click(screen.getByRole('button', { name: 'Confirm' })) expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled() - expect(screen.getByRole('button', { name: 'Select all' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Unselect all' })).toBeDisabled() expect(screen.getByRole('checkbox', { name: 'api' })).toBeDisabled() expect(screen.getByRole('checkbox', { name: 'postgres' })).toBeDisabled() expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx index 4b757bf43f0..3d3d6775839 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx @@ -1,4 +1,3 @@ -import clsx from 'clsx' import { useState } from 'react' import { Button, Checkbox, Heading, Icon, Section } from '@qovery/shared/ui' import { type AgenticWorkflowContextService } from '../../agentic-workflow-context' @@ -19,7 +18,7 @@ export function QoveryServiceContextModal({ const [selectedIds, setSelectedIds] = useState(value.map(({ id }) => id)) const [isSaving, setIsSaving] = useState(false) const [saveError, setSaveError] = useState() - const hideSelectAll = isLoading || services.every(({ id }) => selectedIds.includes(id)) + const hasSelectedService = services.some(({ id }) => selectedIds.includes(id)) return (
    @@ -39,12 +38,10 @@ export function QoveryServiceContextModal({ variant="plain" color="neutral" size="xs" - aria-hidden={hideSelectAll} - disabled={hideSelectAll || isSaving} - className={clsx(hideSelectAll && 'pointer-events-none opacity-0')} - onClick={() => setSelectedIds(services.map(({ id }) => id))} + disabled={isLoading || isSaving} + onClick={() => setSelectedIds(hasSelectedService ? [] : services.map(({ id }) => id))} > - Select all + {hasSelectedService ? 'Unselect all' : 'Select all'}
    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