diff --git a/src/components/organizations/providers-and-models/DisableProviderConfirmDialog.tsx b/src/components/organizations/providers-and-models/DisableProviderConfirmDialog.tsx new file mode 100644 index 000000000..50e811023 --- /dev/null +++ b/src/components/organizations/providers-and-models/DisableProviderConfirmDialog.tsx @@ -0,0 +1,63 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +type Props = { + open: boolean; + providerDisplayName: string; + modelsToRemove: Array<{ modelId: string; modelName: string }>; + onConfirm: () => void; + onCancel: () => void; +}; + +export function DisableProviderConfirmDialog({ + open, + providerDisplayName, + modelsToRemove, + onConfirm, + onCancel, +}: Props) { + return ( + !isOpen && onCancel()}> + + + Disable {providerDisplayName}? + + Disabling this provider will also deselect {modelsToRemove.length}{' '} + {modelsToRemove.length === 1 ? 'model' : 'models'} that would have no remaining enabled + providers. + + + +
+
+ Models to be deselected +
+
+ {modelsToRemove.map(model => ( +
+
{model.modelName}
+
{model.modelId}
+
+ ))} +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx b/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx index 06112cbc4..957843e5f 100644 --- a/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx +++ b/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; import { useOrganizationWithMembers, @@ -19,6 +19,7 @@ import { ModelsTab } from '@/components/organizations/providers-and-models/Model import { ProvidersTab } from '@/components/organizations/providers-and-models/ProvidersTab'; import { ModelDetailsDialog } from '@/components/organizations/providers-and-models/ModelDetailsDialog'; import { ProviderDetailsDialog } from '@/components/organizations/providers-and-models/ProviderDetailsDialog'; +import { DisableProviderConfirmDialog } from '@/components/organizations/providers-and-models/DisableProviderConfirmDialog'; import type { ModelRow, ProviderModelRow, @@ -30,6 +31,7 @@ import { type ProviderPolicyFilter, } from '@/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState'; import { preferredModels } from '@/lib/models'; +import { computeModelsOnlyFromProvider } from '@/components/organizations/providers-and-models/allowLists.domain'; type Props = { organizationId: string; @@ -109,6 +111,12 @@ export function OrganizationProvidersAndModelsPage({ organizationId, role }: Pro openRouterProviders, }); + const [pendingProviderDisable, setPendingProviderDisable] = useState<{ + providerSlug: string; + providerDisplayName: string; + modelsToRemove: Array<{ modelId: string; modelName: string }>; + } | null>(null); + const preferredIndexByModelId = useMemo(() => { const index = new Map(); for (let i = 0; i < preferredModels.length; i++) { @@ -161,17 +169,87 @@ export function OrganizationProvidersAndModelsPage({ organizationId, role }: Pro const handleToggleProviderEnabled = useCallback( (providerSlug: string, nextEnabled: boolean) => { if (!canEdit) return; - actions.toggleProvider({ providerSlug, nextEnabled }); + if (state.status !== 'ready') return; + + // If enabling, just toggle directly + if (nextEnabled) { + actions.toggleProvider({ providerSlug, nextEnabled }); + return; + } + + // If disabling, check if any models would be orphaned + const orphanedModelIds = computeModelsOnlyFromProvider({ + providerSlug, + draftModelAllowList: state.draftModelAllowList, + draftProviderAllowList: state.draftProviderAllowList, + allProviderSlugsWithEndpoints: selectors.allProviderSlugsWithEndpoints, + modelProvidersIndex: selectors.modelProvidersIndex, + }); + + // Filter to only include models that are currently enabled (have at least one enabled provider) + // This prevents showing models that are already disabled in the confirmation dialog + const currentlyEnabledOrphanedModelIds = orphanedModelIds.filter(modelId => + allowedModelIds.has(modelId) + ); + + if (currentlyEnabledOrphanedModelIds.length === 0) { + // No currently enabled models would be orphaned, proceed directly + actions.toggleProvider({ providerSlug, nextEnabled }); + return; + } + + // Find provider display name and model details + const provider = openRouterProviders.find(p => p.slug === providerSlug); + const providerDisplayName = provider?.displayName ?? providerSlug; + + const modelsToRemove = currentlyEnabledOrphanedModelIds + .map(modelId => { + const model = openRouterModels.find(m => normalizeModelId(m.slug) === modelId); + return model ? { modelId, modelName: model.name } : { modelId, modelName: modelId }; + }) + .sort((a, b) => a.modelName.localeCompare(b.modelName)); + + // Show confirmation dialog + setPendingProviderDisable({ + providerSlug, + providerDisplayName, + modelsToRemove, + }); }, - [actions, canEdit] + [ + actions, + allowedModelIds, + canEdit, + openRouterModels, + openRouterProviders, + selectors.allProviderSlugsWithEndpoints, + selectors.modelProvidersIndex, + state, + ] ); const handleToggleModelAllowed = useCallback( (modelId: string, nextAllowed: boolean) => { if (!canEdit) return; + + // If trying to enable a model, check if it has any enabled providers + if (nextAllowed) { + const providersForModel = selectors.modelProvidersIndex.get(modelId); + if (providersForModel) { + const hasEnabledProvider = [...providersForModel].some(providerSlug => + enabledProviderSlugs.has(providerSlug) + ); + if (!hasEnabledProvider) { + // No enabled providers - open the model details dialog instead + actions.setInfoModelId(modelId); + return; + } + } + } + actions.toggleModel({ modelId, nextAllowed }); }, - [actions, canEdit] + [actions, canEdit, enabledProviderSlugs, selectors.modelProvidersIndex] ); const handleToggleAllowFutureModelsForProvider = useCallback( @@ -542,6 +620,22 @@ export function OrganizationProvidersAndModelsPage({ organizationId, role }: Pro onClose={() => actions.setInfoProviderSlug(null)} /> + { + if (pendingProviderDisable) { + actions.toggleProvider({ + providerSlug: pendingProviderDisable.providerSlug, + nextEnabled: false, + }); + setPendingProviderDisable(null); + } + }} + onCancel={() => setPendingProviderDisable(null)} + /> + { expect(next).toEqual([]); }); + + test('computeModelsOnlyFromProvider returns models that would have zero enabled providers', () => { + const modelProvidersIndex = buildModelProvidersIndex([ + { + slug: 'cerebras', + models: [ + { slug: 'cerebras/llama-70b', endpoint: {} }, + { slug: 'shared/model-1', endpoint: {} }, + ], + }, + { + slug: 'openai', + models: [{ slug: 'shared/model-1', endpoint: {} }], + }, + ]); + + const orphaned = computeModelsOnlyFromProvider({ + providerSlug: 'cerebras', + draftModelAllowList: ['cerebras/llama-70b', 'shared/model-1'], + draftProviderAllowList: ['cerebras', 'openai'], + allProviderSlugsWithEndpoints: ['cerebras', 'openai'], + modelProvidersIndex, + }); + + // cerebras/llama-70b is only from cerebras, so it's orphaned + // shared/model-1 is also from openai, so it's NOT orphaned + expect(orphaned).toEqual(['cerebras/llama-70b']); + }); + + test('computeModelsOnlyFromProvider returns empty when no models would be orphaned', () => { + const modelProvidersIndex = buildModelProvidersIndex([ + { + slug: 'cerebras', + models: [{ slug: 'shared/model-1', endpoint: {} }], + }, + { + slug: 'openai', + models: [{ slug: 'shared/model-1', endpoint: {} }], + }, + ]); + + const orphaned = computeModelsOnlyFromProvider({ + providerSlug: 'cerebras', + draftModelAllowList: ['shared/model-1'], + draftProviderAllowList: ['cerebras', 'openai'], + allProviderSlugsWithEndpoints: ['cerebras', 'openai'], + modelProvidersIndex, + }); + + expect(orphaned).toEqual([]); + }); + + test('toggleProviderEnabled(disable) removes orphaned models when modelProvidersIndex is provided', () => { + const modelProvidersIndex = buildModelProvidersIndex([ + { + slug: 'cerebras', + models: [ + { slug: 'cerebras/llama-70b', endpoint: {} }, + { slug: 'shared/model-1', endpoint: {} }, + ], + }, + { + slug: 'openai', + models: [ + { slug: 'openai/gpt-4.1', endpoint: {} }, + { slug: 'shared/model-1', endpoint: {} }, + ], + }, + ]); + + const { nextModelAllowList, nextProviderAllowList } = toggleProviderEnabled({ + providerSlug: 'cerebras', + nextEnabled: false, + draftProviderAllowList: ['cerebras', 'openai'], + draftModelAllowList: ['cerebras/llama-70b', 'openai/gpt-4.1', 'shared/model-1'], + allProviderSlugsWithEndpoints: ['cerebras', 'openai'], + hadAllProvidersInitially: false, + modelProvidersIndex, + }); + + // cerebras/llama-70b should be removed (only from cerebras) + // shared/model-1 should remain (also from openai) + // openai/gpt-4.1 should remain (from openai) + expect(nextModelAllowList.sort()).toEqual(['openai/gpt-4.1', 'shared/model-1']); + expect(nextProviderAllowList).toEqual(['openai']); + }); + + test('computeAllowedModelIds filters out models when their providers are disabled', () => { + const openRouterModels = [ + { slug: 'cerebras/llama-70b' }, + { slug: 'openai/gpt-4.1' }, + { slug: 'shared/model-1' }, + ]; + const openRouterProviders = [ + { + slug: 'cerebras', + models: [ + { slug: 'cerebras/llama-70b', endpoint: {} }, + { slug: 'shared/model-1', endpoint: {} }, + ], + }, + { + slug: 'openai', + models: [ + { slug: 'openai/gpt-4.1', endpoint: {} }, + { slug: 'shared/model-1', endpoint: {} }, + ], + }, + ]; + + // All models are in the allow list + const modelAllowList = ['cerebras/llama-70b', 'openai/gpt-4.1', 'shared/model-1']; + + // But only openai provider is enabled + const enabledProviderSlugs = new Set(['openai']); + + const allowed = computeAllowedModelIds( + modelAllowList, + openRouterModels, + openRouterProviders, + enabledProviderSlugs + ); + + // cerebras/llama-70b should be filtered out (only from cerebras, which is disabled) + // openai/gpt-4.1 should be included (from openai, which is enabled) + // shared/model-1 should be included (available from openai, which is enabled) + expect([...allowed].sort()).toEqual(['openai/gpt-4.1', 'shared/model-1']); + }); + + test('computeAllowedModelIds includes all models when enabledProviderSlugs is not provided', () => { + const openRouterModels = [{ slug: 'cerebras/llama-70b' }, { slug: 'openai/gpt-4.1' }]; + const openRouterProviders = [ + { + slug: 'cerebras', + models: [{ slug: 'cerebras/llama-70b', endpoint: {} }], + }, + { + slug: 'openai', + models: [{ slug: 'openai/gpt-4.1', endpoint: {} }], + }, + ]; + + const modelAllowList = ['cerebras/llama-70b', 'openai/gpt-4.1']; + + // Not passing enabledProviderSlugs - should include all models in allow list + const allowed = computeAllowedModelIds(modelAllowList, openRouterModels, openRouterProviders); + + expect([...allowed].sort()).toEqual(['cerebras/llama-70b', 'openai/gpt-4.1']); + }); + + test('computeModelsOnlyFromProvider expands wildcards to detect orphaned models', () => { + const modelProvidersIndex = buildModelProvidersIndex([ + { + slug: 'cerebras', + models: [ + { slug: 'cerebras/llama-70b', endpoint: {} }, + { slug: 'shared/model-1', endpoint: {} }, + ], + }, + { + slug: 'openai', + models: [{ slug: 'shared/model-1', endpoint: {} }], + }, + ]); + + const orphaned = computeModelsOnlyFromProvider({ + providerSlug: 'cerebras', + draftModelAllowList: ['cerebras/*'], + draftProviderAllowList: ['cerebras', 'openai'], + allProviderSlugsWithEndpoints: ['cerebras', 'openai'], + modelProvidersIndex, + }); + + // cerebras/* expands to cerebras/llama-70b and shared/model-1 + // cerebras/llama-70b is only from cerebras, so it's orphaned + // shared/model-1 is also from openai, so it's NOT orphaned + expect(orphaned).toEqual(['cerebras/llama-70b']); + }); }); diff --git a/src/components/organizations/providers-and-models/allowLists.domain.ts b/src/components/organizations/providers-and-models/allowLists.domain.ts index c6445c956..b05c9c121 100644 --- a/src/components/organizations/providers-and-models/allowLists.domain.ts +++ b/src/components/organizations/providers-and-models/allowLists.domain.ts @@ -93,18 +93,39 @@ export function computeEnabledProviderSlugs( export function computeAllowedModelIds( draftModelAllowList: ReadonlyArray, openRouterModels: ReadonlyArray, - openRouterProviders: OpenRouterProviderModelsSnapshot + openRouterProviders: OpenRouterProviderModelsSnapshot, + enabledProviderSlugs?: ReadonlySet ): Set { const allowed = new Set(); + const modelProvidersIndex = enabledProviderSlugs + ? buildModelProvidersIndex(openRouterProviders) + : null; if (draftModelAllowList.length === 0) { for (const model of openRouterModels) { - allowed.add(normalizeModelId(model.slug)); + const normalizedModelId = normalizeModelId(model.slug); + + // If enabledProviderSlugs is provided, filter models without enabled providers + if (enabledProviderSlugs && modelProvidersIndex) { + const providersForModel = modelProvidersIndex.get(normalizedModelId); + if (!providersForModel) continue; // Exclude models with unknown providers + let hasEnabledProvider = false; + for (const providerSlug of providersForModel) { + if (enabledProviderSlugs.has(providerSlug)) { + hasEnabledProvider = true; + break; + } + } + if (!hasEnabledProvider) continue; + } + + allowed.add(normalizedModelId); } return allowed; } const allowListArray = [...draftModelAllowList]; + for (const model of openRouterModels) { const normalizedModelId = normalizeModelId(model.slug); const isAllowed = isModelAllowedProviderAwareClient( @@ -112,14 +133,98 @@ export function computeAllowedModelIds( allowListArray, openRouterProviders ); - if (isAllowed) { - allowed.add(normalizedModelId); + if (!isAllowed) { + continue; } + + // If enabledProviderSlugs is provided, also check that at least one provider + // offering this model is enabled + if (enabledProviderSlugs && modelProvidersIndex) { + const providersForModel = modelProvidersIndex.get(normalizedModelId); + if (!providersForModel) continue; // Exclude models with unknown providers + let hasEnabledProvider = false; + for (const providerSlug of providersForModel) { + if (enabledProviderSlugs.has(providerSlug)) { + hasEnabledProvider = true; + break; + } + } + if (!hasEnabledProvider) { + continue; + } + } + + allowed.add(normalizedModelId); } return allowed; } +/** + * Compute which models from the current allow list would have zero enabled providers + * if the given provider were disabled. + */ +export function computeModelsOnlyFromProvider(params: { + providerSlug: string; + draftModelAllowList: ReadonlyArray; + draftProviderAllowList: ReadonlyArray; + allProviderSlugsWithEndpoints: ReadonlyArray; + modelProvidersIndex: Map>; +}): string[] { + const { + providerSlug, + draftModelAllowList, + draftProviderAllowList, + allProviderSlugsWithEndpoints, + modelProvidersIndex, + } = params; + + // Compute which providers would remain enabled after disabling this one + const enabledAfterDisable = computeEnabledProviderSlugs( + draftProviderAllowList.length === 0 + ? allProviderSlugsWithEndpoints.filter(slug => slug !== providerSlug) + : draftProviderAllowList.filter(slug => slug !== providerSlug), + allProviderSlugsWithEndpoints + ); + + const orphanedModels: string[] = []; + + // Collect all model IDs to check, expanding wildcards to concrete models + const modelIdsToCheck = new Set(); + for (const entry of draftModelAllowList) { + if (entry.endsWith('/*')) { + const providerPrefix = entry.slice(0, -2); + for (const [modelId, providers] of modelProvidersIndex) { + if (providers.has(providerPrefix)) { + modelIdsToCheck.add(modelId); + } + } + } else { + modelIdsToCheck.add(normalizeModelId(entry)); + } + } + + // Check each model for orphan status + for (const modelId of modelIdsToCheck) { + const providersForModel = modelProvidersIndex.get(modelId); + if (!providersForModel) continue; + + // Check if this model has any enabled providers remaining + let hasEnabledProvider = false; + for (const p of providersForModel) { + if (enabledAfterDisable.has(p)) { + hasEnabledProvider = true; + break; + } + } + if (!hasEnabledProvider) { + orphanedModels.push(modelId); + } + } + + return orphanedModels; +} + export function toggleProviderEnabled(params: { providerSlug: string; nextEnabled: boolean; @@ -127,6 +232,7 @@ export function toggleProviderEnabled(params: { draftModelAllowList: ReadonlyArray; allProviderSlugsWithEndpoints: ReadonlyArray; hadAllProvidersInitially: boolean; + modelProvidersIndex?: Map>; }): { nextProviderAllowList: string[]; nextModelAllowList: string[] } { const { providerSlug, @@ -135,12 +241,32 @@ export function toggleProviderEnabled(params: { draftModelAllowList, allProviderSlugsWithEndpoints, hadAllProvidersInitially, + modelProvidersIndex, } = params; let nextModelAllowList = [...draftModelAllowList]; if (!nextEnabled) { if (nextModelAllowList.length !== 0) { + // Remove provider wildcard nextModelAllowList = nextModelAllowList.filter(entry => entry !== `${providerSlug}/*`); + + // Remove models that would have zero enabled providers + if (modelProvidersIndex) { + const orphanedModels = computeModelsOnlyFromProvider({ + providerSlug, + draftModelAllowList: nextModelAllowList, + draftProviderAllowList, + allProviderSlugsWithEndpoints, + modelProvidersIndex, + }); + if (orphanedModels.length > 0) { + const orphanedSet = new Set(orphanedModels); + nextModelAllowList = nextModelAllowList.filter(entry => { + if (entry.endsWith('/*')) return true; + return !orphanedSet.has(normalizeModelId(entry)); + }); + } + } } } nextModelAllowList = canonicalizeModelAllowList(nextModelAllowList); diff --git a/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.test.ts b/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.test.ts index c74a87224..2b395f595 100644 --- a/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.test.ts +++ b/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.test.ts @@ -4,6 +4,7 @@ import { providersAndModelsAllowListsReducer, type ProvidersAndModelsAllowListsState, } from '@/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState'; +import { buildModelProvidersIndex } from '@/components/organizations/providers-and-models/allowLists.domain'; describe('providersAndModelsAllowListsReducer', () => { test('init -> toggle -> reset returns to initial', () => { @@ -50,11 +51,17 @@ describe('providersAndModelsAllowListsReducer', () => { throw new Error('expected ready state'); } + const modelProvidersIndex = buildModelProvidersIndex([ + { slug: 'openai', models: [] }, + { slug: 'anthropic', models: [] }, + ]); + state = providersAndModelsAllowListsReducer(state, { type: 'TOGGLE_PROVIDER', providerSlug: 'openai', nextEnabled: false, allProviderSlugsWithEndpoints: ['openai', 'anthropic'], + modelProvidersIndex, }); state = providersAndModelsAllowListsReducer(state, { type: 'MARK_SAVED' }); diff --git a/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.ts b/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.ts index 8922c271b..4332e6567 100644 --- a/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.ts +++ b/src/components/organizations/providers-and-models/useProvidersAndModelsAllowListsState.ts @@ -61,6 +61,7 @@ export type ProvidersAndModelsAllowListsAction = providerSlug: string; nextEnabled: boolean; allProviderSlugsWithEndpoints: ReadonlyArray; + modelProvidersIndex: Map>; } | { type: 'TOGGLE_MODEL'; @@ -168,6 +169,7 @@ export function providersAndModelsAllowListsReducer( draftModelAllowList: state.draftModelAllowList, allProviderSlugsWithEndpoints: action.allProviderSlugsWithEndpoints, hadAllProvidersInitially: state.initialProviderAllowList.length === 0, + modelProvidersIndex: action.modelProvidersIndex, }); return { ...state, @@ -319,8 +321,13 @@ export function useProvidersAndModelsAllowListsState(params: { const allowedModelIds = useMemo(() => { if (!draftModelAllowList) return new Set(); - return computeAllowedModelIds(draftModelAllowList, openRouterModels, openRouterProviders); - }, [draftModelAllowList, openRouterModels, openRouterProviders]); + return computeAllowedModelIds( + draftModelAllowList, + openRouterModels, + openRouterProviders, + enabledProviderSlugs + ); + }, [draftModelAllowList, openRouterModels, openRouterProviders, enabledProviderSlugs]); const hasUnsavedChanges = useMemo(() => { if ( @@ -360,9 +367,10 @@ export function useProvidersAndModelsAllowListsState(params: { providerSlug: input.providerSlug, nextEnabled: input.nextEnabled, allProviderSlugsWithEndpoints, + modelProvidersIndex, }); }, - [allProviderSlugsWithEndpoints] + [allProviderSlugsWithEndpoints, modelProvidersIndex] ); const toggleModel = useCallback(