Re-work provider settings to use react-hook-form and dynamic model data (#69)

Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
Co-authored-by: John Richmond <5629+jr@users.noreply.github.com>
This commit is contained in:
Chris Estreich 2025-06-02 16:00:14 -07:00 committed by GitHub
parent 132bf8ae09
commit 9b6ad19db0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1259 additions and 848 deletions

View file

@ -39,6 +39,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
@ -58,6 +59,7 @@
"date-fns": "^4.1.0",
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1",
"fuzzysort": "^3.1.0",
"lucide-react": "^0.509.0",
"next": "^15.3.3",
"next-intl": "^4.1.0",

46
pnpm-lock.yaml generated
View file

@ -44,6 +44,9 @@ importers:
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-popover':
specifier: ^1.1.14
version: 1.1.14(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-separator':
specifier: ^1.1.7
version: 1.1.7(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -101,6 +104,9 @@ importers:
drizzle-zod:
specifier: ^0.7.1
version: 0.7.1(drizzle-orm@0.43.1(@electric-sql/pglite@0.3.0)(@libsql/client-wasm@0.15.5)(@opentelemetry/api@1.9.0)(@types/pg@8.15.2)(pg@8.15.6)(postgres@3.4.7))(zod@3.25.41)
fuzzysort:
specifier: ^3.1.0
version: 3.1.0
lucide-react:
specifier: ^0.509.0
version: 0.509.0(react@19.1.0)
@ -2464,6 +2470,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.14':
resolution: {integrity: sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.7':
resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==}
peerDependencies:
@ -5466,6 +5485,9 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
fuzzysort@3.1.0:
resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@ -11407,6 +11429,28 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.6
'@radix-ui/react-popover@1.1.14(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-popper': 1.2.7(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-presence': 1.1.4(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
aria-hidden: 1.2.6
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-remove-scroll: 2.7.0(@types/react@19.1.6)(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@radix-ui/react-popper@1.2.7(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -14761,6 +14805,8 @@ snapshots:
functions-have-names@1.2.3: {}
fuzzysort@3.1.0: {}
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}

View file

@ -1,220 +1,184 @@
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import type { ProviderName } from '@roo-code/types';
import { type OrganizationSettings, ORGANIZATION_ALLOW_ALL } from '@/types';
import { providers } from '@/lib/providers';
import { type OrganizationAllowList, QueryKey } from '@/types';
import { updateOrganization } from '@/actions/organizationSettings';
import { useAvailableProviders } from '@/hooks/useAvailableProviders';
import { cn } from '@/lib/utils';
import { Switch, Button, Checkbox, Label } from '@/components/ui';
import { MultipleSelector } from '@/components/ui/ecosystem';
import {
Switch,
Button,
Label,
Table,
TableRow,
TableBody,
TableCell,
} from '@/components/ui';
import { MultiSelect } from '@/components/ui/ecosystem';
import { Loading } from '@/components/layout';
type ProviderFormProps = {
orgSettings: OrganizationSettings;
};
export const ProviderForm = () => {
const { availableProviders, allowList } = useAvailableProviders();
const {
watch,
handleSubmit,
setValue,
reset,
formState: { isDirty, isSubmitting },
} = useForm<OrganizationAllowList>({
defaultValues: allowList,
mode: 'onChange',
});
const [allowAll, providers] = watch(['allowAll', 'providers']);
export const ProviderForm = ({ orgSettings }: ProviderFormProps) => {
const queryClient = useQueryClient();
const fullProviderMetadata = useMemo(
() =>
Object.entries(providers).map(([id, provider]) => {
const models =
orgSettings.allowList.providers[id as ProviderName]?.models;
if (models) {
const providerModels = Object.keys(provider.models || {});
const set = new Set(providerModels);
const difference = models.filter((model) => !set.has(model));
if (difference.length > 0) {
return { ...provider, models: [...providerModels, ...difference] };
}
}
return provider;
}),
[orgSettings],
);
const [allowAll, setAllowAll] = useState(orgSettings.allowList.allowAll);
const [providerAllowAll, setProviderAllowAll] = useState(
Object.entries(orgSettings.allowList.providers).reduce(
(acc, [provider, providerSettings]) => {
if (providerSettings.allowAll) {
acc.add(provider);
}
return acc;
},
new Set<string>(),
),
);
const [providerModels, setProviderModels] = useState(
fullProviderMetadata.reduce((acc, meta) => {
const models = Object.keys(
orgSettings.allowList.providers[meta.id]?.models || {},
);
acc.set(meta.id, models);
return acc;
}, new Map<ProviderName, string[]>()),
);
const [hasChanges, setHasChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const isProviderAllowAll = (providerId: ProviderName) =>
providerAllowAll.has(providerId);
const toggleProvider = (providerId: ProviderName) => {
const newProviderAllowAll = new Set(providerAllowAll);
if (providerAllowAll.has(providerId)) {
newProviderAllowAll.delete(providerId);
} else {
newProviderAllowAll.add(providerId);
}
setProviderAllowAll(newProviderAllowAll);
setHasChanges(true);
};
const setModels = (providerId: ProviderName, models: string[]) => {
const newProviderModels = new Map(providerModels);
newProviderModels.set(providerId, models);
setProviderModels(newProviderModels);
setHasChanges(true);
};
const saveChanges = async () => {
setIsSaving(true);
const onSubmit = async (data: OrganizationAllowList) => {
try {
let allowList;
if (allowAll) {
allowList = ORGANIZATION_ALLOW_ALL;
} else {
allowList = {
allowAll: false,
providers: fullProviderMetadata.reduce(
(acc, meta) => {
if (providerAllowAll.has(meta.id)) {
acc[meta.id] = { allowAll: true };
} else {
const models = providerModels.get(meta.id);
if (models && models.length > 0) {
acc[meta.id] = { allowAll: false, models: models };
}
}
return acc;
},
{} as Record<
ProviderName,
{ allowAll: boolean; models?: string[] }
>,
// Filter out providers with no models selected (unless they have
// `allowAll: true`).
const allowList = {
...data,
providers: Object.fromEntries(
Object.entries(data.providers).filter(
([, provider]) =>
provider.allowAll ||
(provider.models && provider.models.length > 0),
),
};
}
),
};
const result = await updateOrganization({ allowList: allowList });
const result = await updateOrganization({ allowList });
if (!result.success) {
throw new Error(result.error || 'Failed to update settings.');
}
queryClient.invalidateQueries({ queryKey: ['organizationSettings'] });
reset(allowList);
queryClient.invalidateQueries({
queryKey: [QueryKey.GetOrganizationSettings],
});
toast.success('Changes will be deployed within 30 seconds.');
setHasChanges(false);
} catch (error) {
console.error('Failed to save provider settings.', error);
toast.error('Failed to save provider settings.');
} finally {
setIsSaving(false);
}
};
const setProvider = ({
id,
enabled = true,
allowAll = true,
models = [],
}: {
id: ProviderName;
enabled?: boolean;
allowAll?: boolean;
models?: string[];
}) => {
const newProviders = { ...providers };
if (enabled) {
newProviders[id] = {
allowAll: models.length > 0 ? false : allowAll,
models,
};
} else {
delete newProviders[id];
}
setValue('providers', newProviders, { shouldDirty: true });
};
return (
<div className="mb-[80px]">
<div className="mb-4 flex items-center space-x-2">
<Label className="text-sm font-medium">
Restrict Providers
<Switch
checked={!allowAll}
onCheckedChange={() => {
setAllowAll(!allowAll);
setHasChanges(true);
}}
disabled={isSaving}
/>
</Label>
</div>
<div className={cn({ hidden: allowAll })}>
{fullProviderMetadata.map((provider) => (
<div key={provider.id} className="mb-4">
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-medium">{provider.label}</div>
<div className="flex items-center space-x-2">
<Label htmlFor={`provider-${provider.id}`} className="text-sm">
Allow all models
</Label>
<Checkbox
id={`provider-${provider.id}`}
checked={isProviderAllowAll(provider.id)}
disabled={allowAll || isSaving}
onCheckedChange={() => toggleProvider(provider.id)}
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-2 mb-[80px]"
>
<Table>
<TableBody>
<TableRow>
<TableCell colSpan={3}>
<Label>
<Switch
checked={allowAll}
onCheckedChange={(checked) =>
setValue('allowAll', checked, { shouldDirty: true })
}
disabled={isSubmitting}
/>
</div>
</div>
<div className="max-w-full">
<MultipleSelector
defaultOptions={Object.keys(provider.models || {}).map(
(model) => ({
label: model,
value: model,
disable: providerModels.get(provider.id)?.includes(model),
}),
Enable All Providers
</Label>
</TableCell>
</TableRow>
{availableProviders.map((provider) => (
<TableRow key={provider.id} className={cn({ hidden: allowAll })}>
<TableCell>
<Label>
<Switch
checked={!!providers[provider.id]}
onCheckedChange={(checked) =>
setProvider({ id: provider.id, enabled: checked })
}
disabled={isSubmitting}
/>
{provider.label}
</Label>
</TableCell>
<TableCell>
{provider.models.length > 0 && (
<Label className={cn({ hidden: !providers[provider.id] })}>
<Switch
checked={providers[provider.id]?.allowAll}
onCheckedChange={(checked) =>
setProvider({ id: provider.id, allowAll: checked })
}
disabled={isSubmitting}
/>
Enable All Models
</Label>
)}
value={(providerModels.get(provider.id) || []).map((model) => ({
label: model,
value: model,
}))}
creatable
disabled={allowAll || isSaving}
placeholder="Pick models..."
onChange={(options) =>
setModels(
provider.id,
options.map((option) => option.value),
)
}
/>
</div>
</div>
))}
</div>
</TableCell>
<TableCell className="min-w-1/2">
{providers[provider.id]?.allowAll === false &&
provider.models.length > 0 && (
<MultiSelect
options={provider.models.map((model) => ({
label: model,
value: model,
}))}
value={providers[provider.id]?.models || []}
placeholder="Select"
onValueChange={(options) =>
setProvider({ id: provider.id, models: options })
}
maxCount={3}
className={cn({ hidden: !providers[provider.id] })}
/>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div
className={cn('fixed bottom-0 left-0 right-0', {
hidden: !hasChanges && !isSaving,
hidden: !isDirty && !isSubmitting,
})}
>
<div className="flex items-center justify-end h-[80px] bg-card border-t px-6">
<Button onClick={saveChanges} disabled={!hasChanges || isSaving}>
{isSaving ? <Loading /> : 'Save Changes'}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Loading /> : 'Save Changes'}
</Button>
</div>
</div>
</div>
</form>
);
};

View file

@ -1,9 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { useQuery } from '@tanstack/react-query';
import { getOrganizationSettings } from '@/actions/organizationSettings';
import {
Card,
CardHeader,
@ -15,14 +13,11 @@ import {
import { Loading } from '@/components/layout';
import { ProviderForm } from './ProviderForm';
import { useOrganizationSettings } from '@/hooks/useOrganizationSettings';
export const ProviderSettings = () => {
const t = useTranslations('ProviderSettings');
const { data: orgSettings } = useQuery({
queryKey: ['getOrganizationSettings'],
queryFn: getOrganizationSettings,
});
const { data, isPending } = useOrganizationSettings();
return (
<>
@ -33,11 +28,11 @@ export const ProviderSettings = () => {
</CardHeader>
<CardContent>
<Badge variant="outline" className="text-xs">
{`Policy v${orgSettings?.version || 1}`}
{`Policy v${data?.version || 1}`}
</Badge>
</CardContent>
</Card>
{orgSettings ? <ProviderForm orgSettings={orgSettings} /> : <Loading />}
{isPending ? <Loading /> : <ProviderForm />}
</>
);
};

View file

@ -0,0 +1,432 @@
// pnpm test src/components/__tests__/ProviderForm.test.tsx
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { toast } from 'sonner';
import { type OrganizationSettings } from '@/types';
import {
updateOrganization,
getOrganizationSettings,
} from '@/actions/organizationSettings';
import { ProviderForm } from '@/app/(authenticated)/providers/ProviderForm';
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
vi.mock('@/actions/organizationSettings', () => ({
updateOrganization: vi.fn(),
getOrganizationSettings: vi.fn(),
}));
vi.mock('@/lib/providers', () => ({
PROVIDERS: {
anthropic: {
id: 'anthropic',
label: 'Anthropic',
models: ['claude-3-5-sonnet-20241022', 'claude-3-haiku-20240307'],
},
'openai-native': {
id: 'openai-native',
label: 'OpenAI',
models: ['gpt-4o', 'gpt-4o-mini'],
},
gemini: {
id: 'gemini',
label: 'Google Gemini',
models: ['gemini-1.5-pro', 'gemini-1.5-flash'],
},
},
}));
vi.mock('@/hooks/useDynamicRouterModels', () => ({
useDynamicRouterModels: () => ({ data: undefined }),
isDynamicRouter: () => false,
}));
vi.mock('@/hooks/useAvailableProviders', () => ({
useAvailableProviders: vi.fn(),
}));
const mockUpdateOrganization = vi.mocked(updateOrganization);
const mockGetOrganizationSettings = vi.mocked(getOrganizationSettings);
const mockToast = vi.mocked(toast);
const { useAvailableProviders } = await import('@/hooks/useAvailableProviders');
const mockUseAvailableProviders = vi.mocked(useAvailableProviders);
const mockAvailableProviders = [
{
id: 'anthropic' as const,
label: 'Anthropic',
models: ['claude-3-5-sonnet-20241022', 'claude-3-haiku-20240307'],
},
{
id: 'openai-native' as const,
label: 'OpenAI',
models: ['gpt-4o', 'gpt-4o-mini'],
},
{
id: 'gemini' as const,
label: 'Google Gemini',
models: ['gemini-1.5-pro', 'gemini-1.5-flash'],
},
];
const createMockOrgSettings = (
allowList: OrganizationSettings['allowList'],
): OrganizationSettings => ({
version: 1,
cloudSettings: {},
defaultSettings: {},
allowList,
});
const renderWithQueryClient = (component: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>,
);
};
const setupMockOrganizationSettings = (
allowList: OrganizationSettings['allowList'],
) => {
const orgSettings = createMockOrgSettings(allowList);
mockGetOrganizationSettings.mockResolvedValue(orgSettings);
// Ensure providers object is always defined to avoid controlled/uncontrolled warnings
const normalizedAllowList = {
...allowList,
providers: allowList.providers || {},
};
mockUseAvailableProviders.mockReturnValue({
availableProviders: mockAvailableProviders,
allowList: normalizedAllowList,
});
};
const getSwitch = (name: string | RegExp) =>
screen.getByRole('switch', { name });
const getSaveButton = () =>
screen.getByRole('button', { name: /save changes/i });
const querySaveButton = () =>
screen.queryByRole('button', { name: /save changes/i });
const waitForSwitch = async (name: string | RegExp, checked: boolean) => {
await waitFor(() => {
const switchElement = getSwitch(name);
if (checked) {
expect(switchElement).toBeChecked();
} else {
expect(switchElement).not.toBeChecked();
}
});
};
const expectSaveButtonVisible = () => {
const saveButton = getSaveButton();
expect(saveButton).toBeInTheDocument();
expect(saveButton).not.toBeDisabled();
};
describe('ProviderForm', () => {
beforeEach(() => {
mockUpdateOrganization.mockResolvedValue({ success: true });
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
describe('initialization', () => {
it('should render with allowAll enabled when allowList.allowAll is true', async () => {
setupMockOrganizationSettings({ allowAll: true, providers: {} });
renderWithQueryClient(<ProviderForm />);
await waitForSwitch(/enable all providers/i, true);
const anthropicRow = screen.getByText('Anthropic').closest('tr');
const openaiRow = screen.getByText('OpenAI').closest('tr');
expect(anthropicRow).toHaveClass('hidden');
expect(openaiRow).toHaveClass('hidden');
});
it('should render with specific providers enabled when allowAll is false', async () => {
setupMockOrganizationSettings({
allowAll: false,
providers: {
anthropic: {
allowAll: false,
models: ['claude-3-5-sonnet-20241022'],
},
'openai-native': { allowAll: false, models: ['gpt-4o'] },
},
});
renderWithQueryClient(<ProviderForm />);
await waitForSwitch(/enable all providers/i, false);
expect(screen.getByText('Anthropic')).toBeVisible();
expect(screen.getByText('OpenAI')).toBeVisible();
expect(screen.getByText('Google Gemini')).toBeVisible();
await waitForSwitch(/anthropic/i, true);
await waitForSwitch(/openai/i, true);
await waitForSwitch(/google gemini/i, false);
});
it('should show save button when form has initial state', async () => {
setupMockOrganizationSettings({ allowAll: false, providers: {} });
renderWithQueryClient(<ProviderForm />);
await waitForSwitch(/enable all providers/i, false);
expectSaveButtonVisible();
});
});
describe('form interactions', () => {
const setupBasicForm = () => {
setupMockOrganizationSettings({ allowAll: false, providers: {} });
renderWithQueryClient(<ProviderForm />);
};
it('should show save button when allowAll is toggled', async () => {
const user = userEvent.setup();
setupBasicForm();
await waitForSwitch(/enable all providers/i, false);
await user.click(getSwitch(/enable all providers/i));
expectSaveButtonVisible();
});
it('should show save button when provider is toggled', async () => {
const user = userEvent.setup();
setupBasicForm();
await waitForSwitch(/anthropic/i, false);
await user.click(getSwitch(/anthropic/i));
expectSaveButtonVisible();
});
it('should hide provider controls when allowAll is enabled', async () => {
const user = userEvent.setup();
setupMockOrganizationSettings({
allowAll: false,
providers: { anthropic: { allowAll: false, models: [] } },
});
renderWithQueryClient(<ProviderForm />);
await waitFor(() => expect(screen.getByText('Anthropic')).toBeVisible());
await user.click(getSwitch(/enable all providers/i));
const anthropicRow = screen.getByText('Anthropic').closest('tr');
expect(anthropicRow).toHaveClass('hidden');
});
it('should show model selection when provider is enabled', async () => {
const user = userEvent.setup();
setupBasicForm();
await waitForSwitch(/anthropic/i, false);
await user.click(getSwitch(/anthropic/i));
const anthropicRow = screen.getByText('Anthropic').closest('tr');
const enableAllModelsLabel = within(anthropicRow!).getByText(
'Enable All Models',
);
expect(enableAllModelsLabel).toBeVisible();
});
it('should handle All button click for models', async () => {
const user = userEvent.setup();
setupMockOrganizationSettings({
allowAll: false,
providers: { anthropic: { allowAll: false, models: [] } },
});
renderWithQueryClient(<ProviderForm />);
await waitForSwitch(/anthropic/i, true);
const enableAllModelsSwitches = screen.getAllByRole('switch', {
name: /enable all models/i,
});
await user.click(enableAllModelsSwitches[0]!);
expectSaveButtonVisible();
});
it('should handle None button click for models', async () => {
setupMockOrganizationSettings({
allowAll: false,
providers: {
anthropic: {
allowAll: false,
models: ['claude-3-5-sonnet-20241022'],
},
},
});
renderWithQueryClient(<ProviderForm />);
await waitForSwitch(/anthropic/i, true);
expectSaveButtonVisible();
});
});
describe('form submission', () => {
const setupFormAndSubmit = async (
switchName: string | RegExp,
expectedPayload: OrganizationSettings['allowList'],
) => {
const user = userEvent.setup();
setupMockOrganizationSettings({ allowAll: false, providers: {} });
renderWithQueryClient(<ProviderForm />);
await waitForSwitch(switchName, false);
await user.click(getSwitch(switchName));
await user.click(getSaveButton());
await waitFor(() => {
expect(mockUpdateOrganization).toHaveBeenCalledWith({
allowList: expectedPayload,
});
});
expect(mockToast.success).toHaveBeenCalledWith(
'Changes will be deployed within 30 seconds.',
);
};
it('should save allowAll configuration successfully', async () => {
await setupFormAndSubmit(/enable all providers/i, {
allowAll: true,
providers: {},
});
});
it('should save specific provider configuration successfully', async () => {
await setupFormAndSubmit(/anthropic/i, {
allowAll: false,
providers: { anthropic: { allowAll: true, models: [] } },
});
});
const testErrorHandling = async (mockSetup: () => void) => {
const user = userEvent.setup();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
setupMockOrganizationSettings({ allowAll: false, providers: {} });
mockSetup();
renderWithQueryClient(<ProviderForm />);
await waitForSwitch(/enable all providers/i, false);
await user.click(getSwitch(/enable all providers/i));
await user.click(getSaveButton());
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to save provider settings.',
);
});
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
};
it('should handle save error gracefully', async () => {
await testErrorHandling(() => {
mockUpdateOrganization.mockResolvedValue({
success: false,
error: 'Network error',
});
});
});
it('should handle save exception gracefully', async () => {
await testErrorHandling(() => {
mockUpdateOrganization.mockRejectedValue(new Error('Network error'));
});
});
it('should disable form controls while saving', async () => {
const user = userEvent.setup();
setupMockOrganizationSettings({ allowAll: false, providers: {} });
// Make the update function hang to test loading state
mockUpdateOrganization.mockImplementation(() => new Promise(() => {}));
renderWithQueryClient(<ProviderForm />);
await waitForSwitch(/enable all providers/i, false);
const allowAllSwitch = getSwitch(/enable all providers/i);
await user.click(allowAllSwitch);
const saveButton = getSaveButton();
await user.click(saveButton);
await waitFor(() => {
expect(allowAllSwitch).toBeDisabled();
// Save button might be hidden during submission, so check if it exists first
const currentSaveButton = querySaveButton();
if (currentSaveButton) {
expect(currentSaveButton).toBeDisabled();
}
});
});
});
describe('form state management', () => {
it('should track form dirty state correctly', async () => {
const user = userEvent.setup();
setupMockOrganizationSettings({
allowAll: false,
providers: {
anthropic: {
allowAll: false,
models: ['claude-3-5-sonnet-20241022'],
},
},
});
renderWithQueryClient(<ProviderForm />);
await waitForSwitch(/enable all providers/i, false);
expectSaveButtonVisible();
const allowAllSwitch = getSwitch(/enable all providers/i);
await user.click(allowAllSwitch);
expect(getSaveButton()).toBeVisible();
await user.click(allowAllSwitch);
// After reverting, save button should still be visible due to initialization state
expectSaveButtonVisible();
});
it('should preserve provider allowAll settings', async () => {
const user = userEvent.setup();
setupMockOrganizationSettings({
allowAll: false,
providers: {
anthropic: { allowAll: true },
'openai-native': { allowAll: false, models: ['gpt-4o'] },
},
});
renderWithQueryClient(<ProviderForm />);
await waitForSwitch(/anthropic/i, true);
await user.click(getSwitch(/google gemini/i));
await user.click(getSaveButton());
await waitFor(() => {
expect(mockUpdateOrganization).toHaveBeenCalledWith({
allowList: {
allowAll: false,
providers: {
anthropic: { allowAll: true },
'openai-native': { allowAll: false, models: ['gpt-4o'] },
gemini: { allowAll: true, models: [] },
},
},
});
});
});
});
});

View file

@ -1,3 +1,3 @@
export * from './button';
export * from './multiple-selector';
export * from './multi-select';
export * from './tabs';

View file

@ -0,0 +1,344 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import fuzzysort from 'fuzzysort';
import { Check, X, ChevronsUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '../badge';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '../command';
/**
* Variants for the multi-select component to handle different styles.
* Uses class-variance-authority (cva) to define different styles based on
* "variant" prop.
*/
const multiSelectVariants = cva('px-2 py-1', {
variants: {
variant: {
default: 'border-foreground/10 text-foreground bg-card hover:bg-card/80',
secondary:
'border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
inverted: 'bg-background',
},
},
defaultVariants: {
variant: 'default',
},
});
interface MultiSelectProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof multiSelectVariants> {
/**
* An array of option objects to be displayed in the multi-select component.
* Each option object has a label and value.
*/
options: {
/** The text to display for the option. */
label: string;
/** The unique value associated with the option. */
value: string;
}[];
/**
* Callback function triggered when the selected values change.
* Receives an array of the new selected values.
*/
onValueChange: (value: string[]) => void;
/** The default selected values when the component mounts. */
defaultValue?: string[];
/** The controlled selected values. When provided, the component becomes controlled. */
value?: string[];
/**
* Placeholder text to be displayed when no values are selected.
* Optional, defaults to "Select options".
*/
placeholder?: string;
/**
* Maximum number of items to display. Extra selected items will be summarized.
* Optional, defaults to 3.
*/
maxCount?: number;
/**
* The modality of the popover. When set to true, interaction with outside elements
* will be disabled and only popover content will be visible to screen readers.
* Optional, defaults to false.
*/
modalPopover?: boolean;
/**
* If true, renders the multi-select component as a child of another component.
* Optional, defaults to false.
*/
asChild?: boolean;
/**
* Additional class names to apply custom styles to the multi-select component.
* Optional, can be used to add custom styles.
*/
className?: string;
}
export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
(
{
options,
onValueChange,
variant,
defaultValue = [],
value,
placeholder = 'Select options',
maxCount = 3,
modalPopover = false,
className,
...props
},
ref,
) => {
const [internalSelectedValues, setInternalSelectedValues] =
React.useState<string[]>(defaultValue);
// Use controlled value if provided, otherwise use internal state.
const selectedValues = value !== undefined ? value : internalSelectedValues;
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (event.key === 'Enter') {
setIsPopoverOpen(true);
} else if (event.key === 'Backspace' && !event.currentTarget.value) {
const newSelectedValues = [...selectedValues];
newSelectedValues.pop();
if (value !== undefined) {
// Controlled mode - only call `onValueChange`.
onValueChange(newSelectedValues);
} else {
// Uncontrolled mode - update internal state and call `onValueChange`.
setInternalSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
}
}
};
const toggleOption = (option: string) => {
const newSelectedValues = selectedValues.includes(option)
? selectedValues.filter((value) => value !== option)
: [...selectedValues, option];
if (value !== undefined) {
// Controlled mode - only call `onValueChange`.
onValueChange(newSelectedValues);
} else {
// Uncontrolled mode - update internal state and call `onValueChange`.
setInternalSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
}
};
const handleTogglePopover = () => setIsPopoverOpen((prev) => !prev);
const clearExtraOptions = () => {
const newSelectedValues = selectedValues.slice(0, maxCount);
if (value !== undefined) {
// Controlled mode - only call `onValueChange`.
onValueChange(newSelectedValues);
} else {
// Uncontrolled mode - update internal state and call `onValueChange`.
setInternalSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
}
};
const searchResultsRef = React.useRef<Map<string, number>>(new Map());
const searchValueRef = React.useRef('');
const onSelectAll = () => {
// If there's no search term, use all options. Otherwise, use filtered results.
const values =
searchValueRef.current.trim() === ''
? options.map((option) => option.value)
: Array.from(searchResultsRef.current.keys());
let newSelectedValues: string[];
if (
selectedValues.length === values.length &&
selectedValues.sort().join(',') === values.sort().join(',')
) {
newSelectedValues = [];
} else {
newSelectedValues = values;
}
if (value !== undefined) {
// Controlled mode - only call `onValueChange`.
onValueChange(newSelectedValues);
} else {
// Uncontrolled mode - update internal state and call `onValueChange`.
setInternalSelectedValues(newSelectedValues);
onValueChange(newSelectedValues);
}
};
const onFilter = React.useCallback(
(value: string, search: string) => {
if (searchValueRef.current !== search) {
searchValueRef.current = search;
searchResultsRef.current.clear();
for (const {
obj: { value },
score,
} of fuzzysort.go(search, options, {
key: 'label',
})) {
searchResultsRef.current.set(value, score);
}
}
if (value === 'all') {
return searchResultsRef.current.size > 1 ? 0.01 : 0;
}
return searchResultsRef.current.get(value) ?? 0;
},
[options],
);
return (
<Popover
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
modal={modalPopover}
>
<PopoverTrigger asChild>
<div
ref={ref}
{...props}
onClick={handleTogglePopover}
className={cn(
'flex w-full rounded-sm min-h-9 h-auto items-center justify-between [&_svg]:pointer-events-auto',
'font-medium border border-input bg-input hover:opacity-80 cursor-pointer',
className,
)}
>
{selectedValues.length > 0 ? (
<div className="flex justify-between items-center w-full">
<div className="flex flex-wrap items-center gap-1 p-1">
{selectedValues.slice(0, maxCount).map((value) => (
<Badge
key={value}
className={cn(multiSelectVariants({ variant }))}
>
<div className="flex items-center gap-1.5">
<div>
{options.find((o) => o.value === value)?.label}
</div>
<div
onClick={(event) => {
event.stopPropagation();
toggleOption(value);
}}
className="cursor-pointer"
>
<X className="size-4 rounded-full p-0.5 bg-accent/5" />
</div>
</div>
</Badge>
))}
{selectedValues.length > maxCount && (
<Badge
className={cn(
'text-ring',
multiSelectVariants({ variant }),
)}
>
<div className="flex items-center gap-1.5">
<div>{`+ ${selectedValues.length - maxCount} more`}</div>
<div
onClick={(event) => {
event.stopPropagation();
clearExtraOptions();
}}
className="cursor-pointer"
>
<X className="size-4 rounded-full p-0.5 bg-ring/5" />
</div>
</div>
</Badge>
)}
</div>
</div>
) : (
<div className="flex items-center justify-between w-full mx-auto">
<span className="text-muted-foreground mx-3">
{placeholder}
</span>
<ChevronsUpDown className="opacity-50 size-4 mx-2" />
</div>
)}
</div>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command filter={onFilter}>
<CommandInput placeholder="Search" onKeyDown={handleInputKeyDown} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => toggleOption(option.value)}
className="flex items-center justify-between"
>
<span>{option.label}</span>
<Check
className={cn(
'text-accent group-data-[selected=true]:text-accent-foreground size-4',
{ 'opacity-0': !selectedValues.includes(option.value) },
)}
/>
</CommandItem>
))}
<CommandItem
key="all"
value="all"
onSelect={onSelectAll}
className="flex items-center justify-between"
>
<span>Select All</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
},
);
MultiSelect.displayName = 'MultiSelect';

View file

@ -1,636 +0,0 @@
'use client';
import * as React from 'react';
import { forwardRef, useEffect } from 'react';
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Badge,
Command,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui';
export interface Option {
value: string;
label: string;
disable?: boolean;
/** fixed option that can't be removed. */
fixed?: boolean;
/** Group the options by providing key. */
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: Option[];
}
interface MultipleSelectorProps {
value?: Option[];
defaultOptions?: Option[];
/** manually controlled options */
options?: Option[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: React.ReactNode;
/** Empty component. */
emptyIndicator?: React.ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
**/
triggerSearchOnFocus?: boolean;
/** async search */
onSearch?: (value: string) => Promise<Option[]>;
/**
* sync search. This search will not showing loadingIndicator.
* The rest props are the same as async search.
* i.e.: creatable, groupBy, delay.
**/
onSearchSync?: (value: string) => Option[];
onChange?: (options: Option[]) => void;
/** Limit the maximum number of selected options. */
maxSelected?: number;
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
onMaxSelected?: (maxLimit: number) => void;
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
*
* @reference: https://github.com/pacocoursey/cmdk/issues/171
*/
selectFirstItem?: boolean;
/** Allow user to create option when there is no option matched. */
creatable?: boolean;
/** Props of `Command` */
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
/** Props of `CommandInput` */
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
'value' | 'placeholder' | 'disabled'
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
}
export interface MultipleSelectorRef {
selectedValue: Option[];
input: HTMLInputElement;
focus: () => void;
reset: () => void;
}
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
function transToGroupOption(options: Option[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
'': options,
};
}
const groupOption: GroupOption = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || '';
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter(
(val) => !picked.find((p) => p.value === val.value),
);
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (
value.some((option) => targetOption.find((p) => p.value === option.value))
) {
return true;
}
}
return false;
}
/**
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
* So we create one and copy the `Empty` implementation from `cmdk`.
*
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
**/
const CommandEmpty = forwardRef<
HTMLDivElement,
React.ComponentProps<typeof CommandPrimitive.Empty>
>(({ className, ...props }, forwardedRef) => {
const render = useCommandState((state) => state.filtered.count === 0);
if (!render) return null;
return (
<div
ref={forwardedRef}
className={cn('py-6 text-center text-sm', className)}
// eslint-disable-next-line react/no-unknown-property
cmdk-empty=""
role="presentation"
{...props}
/>
);
});
CommandEmpty.displayName = 'CommandEmpty';
export const MultipleSelector = React.forwardRef<
MultipleSelectorRef,
MultipleSelectorProps
>(
(
{
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
onSearchSync,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
hideClearAllButton = false,
}: MultipleSelectorProps,
ref: React.Ref<MultipleSelectorRef>,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [onScrollbar, setOnScrollbar] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
const [selected, setSelected] = React.useState<Option[]>(value || []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState('');
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current as HTMLInputElement,
focus: () => inputRef?.current?.focus(),
reset: () => setSelected([]),
}),
[selected],
);
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setOpen(false);
inputRef.current.blur();
}
};
const handleUnselect = React.useCallback(
(option: Option) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '' && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (lastSelectOption && !lastSelectOption.fixed) {
handleUnselect(lastSelectOption);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === 'Escape') {
input.blur();
}
}
},
[handleUnselect, selected],
);
useEffect(() => {
if (open) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchend', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
};
}, [open]);
useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);
useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
/** sync search */
const doSearchSync = () => {
const res = onSearchSync?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
};
const exec = async () => {
if (!onSearchSync || !open) return;
if (triggerSearchOnFocus) {
doSearchSync();
}
if (debouncedSearchTerm) {
doSearchSync();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
useEffect(() => {
/** async search */
const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
setIsLoading(false);
};
const exec = async () => {
if (!onSearch || !open) return;
if (triggerSearchOnFocus) {
await doSearch();
}
if (debouncedSearchTerm) {
await doSearch();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}
const Item = (
<CommandItem
value={inputValue}
className="cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
{`Create "${inputValue}"`}
</CommandItem>
);
// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}
// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}
return undefined;
};
const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined;
// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (
<CommandItem value="-" disabled>
{emptyIndicator}
</CommandItem>
);
}
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don't have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
return (
<Command
ref={dropdownRef}
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn(
'h-auto overflow-visible bg-transparent',
commandProps?.className,
)}
shouldFilter={
commandProps?.shouldFilter !== undefined
? commandProps.shouldFilter
: !onSearch
} // When onSearch is provided, we don't want to filter the options. You can still override it.
filter={commandFilter()}
>
<div
className={cn(
'min-h-10 rounded-md border border-input text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm',
{
'px-3 py-2': selected.length !== 0,
'cursor-text': !disabled && selected.length !== 0,
},
className,
)}
onClick={() => {
if (disabled) return;
inputRef?.current?.focus();
}}
>
<div className="relative flex flex-wrap gap-1">
{selected.map((option) => {
return (
<Badge
key={option.value}
className={cn(
'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground',
'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground',
badgeClassName,
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label}
<button
type="button"
className={cn(
'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2',
(disabled || option.fixed) && 'hidden',
)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
if (!onScrollbar) {
setOpen(false);
}
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
inputProps?.onFocus?.(event);
}}
placeholder={
hidePlaceholderWhenSelected && selected.length !== 0
? ''
: placeholder
}
className={cn(
'flex-1 bg-transparent outline-none placeholder:text-muted-foreground',
{
'w-full': hidePlaceholderWhenSelected,
'px-3 py-2': selected.length === 0,
'ml-1': selected.length !== 0,
},
inputProps?.className,
)}
/>
<button
type="button"
onClick={() => {
setSelected(selected.filter((s) => s.fixed));
onChange?.(selected.filter((s) => s.fixed));
}}
className={cn(
'absolute right-0 h-6 w-6 p-0',
(hideClearAllButton ||
disabled ||
selected.length < 1 ||
selected.filter((s) => s.fixed).length === selected.length) &&
'hidden',
)}
>
<X />
</button>
</div>
</div>
<div className="relative">
{open && (
<CommandList
className="absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in"
onMouseLeave={() => {
setOnScrollbar(false);
}}
onMouseEnter={() => {
setOnScrollbar(true);
}}
onMouseUp={() => {
inputRef?.current?.focus();
}}
>
{isLoading ? (
<>{loadingIndicator}</>
) : (
<>
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && (
<CommandItem value="-" className="hidden" />
)}
{Object.entries(selectables).map(([key, dropdowns]) => (
<CommandGroup
key={key}
heading={key}
className="h-full overflow-auto"
>
<>
{dropdowns.map((option) => {
return (
<CommandItem
key={option.value}
value={option.label}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
'cursor-pointer',
option.disable &&
'cursor-default text-muted-foreground',
)}
>
{option.label}
</CommandItem>
);
})}
</>
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</Command>
);
},
);
MultipleSelector.displayName = 'MultipleSelector';

View file

@ -8,6 +8,7 @@ export * from './dropdown-menu';
export * from './form';
export * from './input';
export * from './label';
export * from './popover';
export * from './separator';
export * from './skeleton';
export * from './slider';

View file

@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View file

@ -0,0 +1,53 @@
import { useMemo } from 'react';
import type { ProviderName } from '@roo-code/types';
import { PROVIDERS } from '@/lib/providers';
import { useOrganizationSettings } from './useOrganizationSettings';
import {
useDynamicRouterModels,
isDynamicRouter,
} from './useDynamicRouterModels';
export const useAvailableProviders = () => {
const { data: organizationSettings } = useOrganizationSettings();
const { data: dynamicRouterModels } = useDynamicRouterModels();
const allowList = organizationSettings?.allowList;
const availableProviders = useMemo(
() =>
allowList
? Object.entries(PROVIDERS)
.map(([id, { models, ...provider }]) => {
const providerId = id as ProviderName;
if (
isDynamicRouter(providerId) &&
dynamicRouterModels?.[providerId]
) {
models = dynamicRouterModels[providerId];
}
const set = new Set(models);
return {
...provider,
models: [
...models,
// Make sure we include models that are already persisted in the
// database even if they are no longer available on the provider.
...(allowList.providers[providerId]?.models ?? []).filter(
(model) => !set.has(model),
),
],
};
})
.sort((a, b) => a.label.localeCompare(b.label))
: [],
[allowList, dynamicRouterModels],
);
return { availableProviders, allowList };
};

View file

@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { QueryKey } from '@/types';
import { getDynamicRouterModels } from '@/lib/server/models';
export const dynamicRouters = [
'openrouter',
'requesty',
'unbound',
'glama',
] as const;
export type DynamicRouter = (typeof dynamicRouters)[number];
export const useDynamicRouterModels = () =>
useQuery({
queryKey: [QueryKey.GetDynamicRouterModels],
queryFn: () => getDynamicRouterModels(),
});
export const isDynamicRouter = (key: string): key is DynamicRouter =>
dynamicRouters.includes(key as DynamicRouter);

View file

@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { QueryKey } from '@/types';
import { getOrganizationSettings } from '@/actions/organizationSettings';
export const useOrganizationSettings = () =>
useQuery({
queryKey: [QueryKey.GetOrganizationSettings],
queryFn: getOrganizationSettings,
});

View file

@ -1,6 +1,5 @@
import {
type ProviderName,
type ModelInfo,
anthropicModels,
bedrockModels,
chutesModels,
@ -14,38 +13,66 @@ import {
xaiModels,
} from '@roo-code/types';
export const providers: Record<
export const PROVIDERS: Record<
Exclude<ProviderName, 'fake-ai' | 'human-relay'>,
{ id: ProviderName; label: string; models?: Record<string, ModelInfo> }
{ id: ProviderName; label: string; models: string[] }
> = {
anthropic: { id: 'anthropic', label: 'Anthropic', models: anthropicModels },
bedrock: { id: 'bedrock', label: 'Amazon Bedrock', models: bedrockModels },
chutes: { id: 'chutes', label: 'Chutes AI', models: chutesModels },
deepseek: { id: 'deepseek', label: 'DeepSeek', models: deepSeekModels },
gemini: { id: 'gemini', label: 'Google Gemini', models: geminiModels },
anthropic: {
id: 'anthropic',
label: 'Anthropic',
models: Object.keys(anthropicModels),
},
bedrock: {
id: 'bedrock',
label: 'Amazon Bedrock',
models: Object.keys(bedrockModels),
},
chutes: {
id: 'chutes',
label: 'Chutes AI',
models: Object.keys(chutesModels),
},
deepseek: {
id: 'deepseek',
label: 'DeepSeek',
models: Object.keys(deepSeekModels),
},
gemini: {
id: 'gemini',
label: 'Google Gemini',
models: Object.keys(geminiModels),
},
'openai-native': {
id: 'openai-native',
label: 'OpenAI',
models: openAiNativeModels,
models: Object.keys(openAiNativeModels),
},
vertex: {
id: 'vertex',
label: 'GCP Vertex AI',
models: Object.keys(vertexModels),
},
vertex: { id: 'vertex', label: 'GCP Vertex AI', models: vertexModels },
'vscode-lm': {
id: 'vscode-lm',
label: 'VS Code LM API',
models: vscodeLlmModels,
models: Object.keys(vscodeLlmModels),
},
mistral: { id: 'mistral', label: 'Mistral', models: mistralModels },
xai: { id: 'xai', label: 'xAI (Grok)', models: xaiModels },
groq: { id: 'groq', label: 'Groq', models: groqModels },
mistral: {
id: 'mistral',
label: 'Mistral',
models: Object.keys(mistralModels),
},
xai: { id: 'xai', label: 'xAI (Grok)', models: Object.keys(xaiModels) },
groq: { id: 'groq', label: 'Groq', models: Object.keys(groqModels) },
openai: { id: 'openai', label: 'OpenAI Compatible' }, // Models are manually added.
ollama: { id: 'ollama', label: 'Ollama' }, // Models pulled locally from the Ollama server.
lmstudio: { id: 'lmstudio', label: 'LM Studio' }, // Not sure...
openai: { id: 'openai', label: 'OpenAI Compatible', models: [] }, // Models are manually added.
ollama: { id: 'ollama', label: 'Ollama', models: [] }, // Models pulled locally from the Ollama server.
lmstudio: { id: 'lmstudio', label: 'LM Studio', models: [] }, // Not sure...
// Models pulled from the respective APIs.
openrouter: { id: 'openrouter', label: 'OpenRouter' },
requesty: { id: 'requesty', label: 'Requesty' },
litellm: { id: 'litellm', label: 'LiteLLM' },
unbound: { id: 'unbound', label: 'Unbound' },
glama: { id: 'glama', label: 'Glama' },
openrouter: { id: 'openrouter', label: 'OpenRouter', models: [] },
requesty: { id: 'requesty', label: 'Requesty', models: [] },
litellm: { id: 'litellm', label: 'LiteLLM', models: [] },
unbound: { id: 'unbound', label: 'Unbound', models: [] },
glama: { id: 'glama', label: 'Glama', models: [] },
};

85
src/lib/server/models.ts Normal file
View file

@ -0,0 +1,85 @@
'use server';
import { unstable_cache } from 'next/cache';
import z from 'zod';
const fetchProviderModels = async <T>({
url,
schema,
}: {
url: string;
schema: z.ZodSchema<T>;
}): Promise<T> => {
const response = await fetch(url);
if (!response.ok) {
console.error(response);
throw new Error('Failed to fetch provider models');
}
return schema.parse(await response.json());
};
const fetchOpenRouterModels = async () => {
const result = await fetchProviderModels({
url: 'https://openrouter.ai/api/v1/models',
schema: z.object({
data: z.array(z.object({ id: z.string() })),
}),
});
return result.data.map(({ id }) => id);
};
const fetchRequestyModels = async () => {
const result = await fetchProviderModels({
url: 'https://router.requesty.ai/v1/models',
schema: z.object({
data: z.array(z.object({ id: z.string() })),
}),
});
return result.data.map(({ id }) => id);
};
const fetchUnboundModels = async () => {
const result = await fetchProviderModels({
url: 'https://api.getunbound.ai/models',
schema: z.record(z.string(), z.any()),
});
return Object.keys(result.data);
};
const fetchGlamaModels = async () => {
const result = await fetchProviderModels({
url: 'https://glama.ai/api/gateway/v1/models',
schema: z.array(z.object({ id: z.string() })),
});
return result.map(({ id }) => id);
};
export const getDynamicRouterModels = unstable_cache(
async () => {
const [openRouterModels, requestyModels, unboundModels, glamaModels] =
await Promise.all([
fetchOpenRouterModels().catch(() => []),
fetchRequestyModels().catch(() => []),
fetchUnboundModels().catch(() => []),
fetchGlamaModels().catch(() => []),
]);
return {
openrouter: openRouterModels,
requesty: requestyModels,
unbound: unboundModels,
glama: glamaModels,
};
},
['getDynamicRouterModels'],
{
revalidate: 60 * 60,
tags: ['getDynamicRouterModels'],
},
);

View file

@ -1,4 +1,5 @@
export * from './api';
export * from './auth';
export * from './org';
export * from './react-query';
export * from './time-period';

4
src/types/react-query.ts Normal file
View file

@ -0,0 +1,4 @@
export enum QueryKey {
GetOrganizationSettings = 'GetOrganizationSettings',
GetDynamicRouterModels = 'GetDynamicRouterModels',
}

View file

@ -9,4 +9,16 @@ failOnConsole({
shouldFailOnWarn: true,
});
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
beforeEach(() => vi.clearAllMocks());