diff --git a/apps/web/src/app/(app)/organizations/[id]/custom-modes/page.tsx b/apps/web/src/app/(app)/organizations/[id]/custom-modes/page.tsx index e28b0ebbde..86b0a54b0c 100644 --- a/apps/web/src/app/(app)/organizations/[id]/custom-modes/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/custom-modes/page.tsx @@ -9,7 +9,13 @@ export default async function OrganizationCustomModesPage({ return ( } + render={({ organization, role, isGlobalAdmin }) => ( + + )} /> ); } diff --git a/apps/web/src/app/api/openrouter/[...path]/route.test.ts b/apps/web/src/app/api/openrouter/[...path]/route.test.ts index 7914abe091..265cc9724a 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.test.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.test.ts @@ -13,6 +13,7 @@ import type { Provider } from '@/lib/ai-gateway/providers/types'; import { fetchEfficientAutoDecision } from '@/lib/ai-gateway/auto-routing-decision'; import { logMicrodollarUsage } from '@/lib/ai-gateway/processUsage'; import { applyResolvedAutoModel } from '@/lib/ai-gateway/auto-model/resolution'; +import { getDirectByokModel } from '@/lib/ai-gateway/providers/direct-byok'; jest.mock('next/server', () => { return { @@ -40,6 +41,9 @@ jest.mock('@/lib/ai-gateway/abuse-service', () => { }; }); jest.mock('@/lib/ai-gateway/providers/get-provider'); +jest.mock('@/lib/ai-gateway/providers/direct-byok', () => ({ + getDirectByokModel: jest.fn(async () => ({ provider: null, model: null })), +})); jest.mock('@/lib/ai-gateway/providers/upstream-request'); jest.mock('@/lib/ai-gateway/providers/gateway-models-cache'); jest.mock('@/lib/redis', () => ({ @@ -90,6 +94,7 @@ const mockedRedisSet = jest.mocked(redisClient.set); const mockedFetchEfficientAutoDecision = jest.mocked(fetchEfficientAutoDecision); const mockedLogMicrodollarUsage = jest.mocked(logMicrodollarUsage); const mockedApplyResolvedAutoModel = jest.mocked(applyResolvedAutoModel); +const mockedGetDirectByokModel = jest.mocked(getDirectByokModel); const provider = { id: 'openrouter', @@ -413,7 +418,9 @@ describe('POST /api/openrouter/v1/chat/completions rules-engine actions', () => describe('kilo-auto/efficient classifier billing', () => { beforeEach(() => { jest.clearAllMocks(); + mockedGetDirectByokModel.mockResolvedValue({ provider: null, model: null }); setUserAuth(); + mockedGetProvider.mockResolvedValue({ kind: 'provider', provider, @@ -443,6 +450,48 @@ describe('kilo-auto/efficient classifier billing', () => { }); }); + it('rejects Organization Auto direct-BYOK routes when provider selection falls through', async () => { + mockedGetUserFromAuth.mockResolvedValue({ + user: { + id: 'user-123', + google_user_email: 'test@example.com', + microdollars_used: 0, + } as User, + authFailedResponse: null, + organizationId: 'org-1', + }); + mockedGetBalanceAndOrgSettings.mockResolvedValue({ + balance: 1000, + settings: { + default_model: 'kilo-auto/org', + org_auto_model: { routes: {}, fallback_model: 'kilo-auto/balanced' }, + }, + plan: 'enterprise', + }); + mockedApplyResolvedAutoModel.mockImplementation(async (_params, request) => { + request.body.model = 'martian/moonshotai/kimi-k2.6'; + return { + kind: 'ok', + resolved: { model: 'martian/moonshotai/kimi-k2.6' }, + routingTarget: 'martian/moonshotai/kimi-k2.6', + }; + }); + mockedGetDirectByokModel.mockResolvedValue({ + provider: { id: 'martian' } as never, + model: {} as never, + }); + + const { POST } = await import('./route'); + const response = await POST(makeRequest(makeBody('kilo-auto/org')) as never); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error_type: 'organization_auto_configuration', + message: expect.stringContaining('does not have an enabled BYOK credential for martian'), + }); + expect(mockedUpstreamRequest).not.toHaveBeenCalled(); + }); + it('bills classifier cost when cost > 0 and user is non-BYOK', async () => { mockedFetchEfficientAutoDecision.mockResolvedValue({ decision: { diff --git a/apps/web/src/app/api/openrouter/[...path]/route.ts b/apps/web/src/app/api/openrouter/[...path]/route.ts index 7119240a7a..308305c462 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.ts @@ -17,6 +17,7 @@ import type { } from '@/lib/ai-gateway/providers/openrouter/types'; import { applyProviderSpecificLogic } from '@/lib/ai-gateway/providers/apply-provider-specific-logic'; import { getProvider } from '@/lib/ai-gateway/providers/get-provider'; +import { getDirectByokModel } from '@/lib/ai-gateway/providers/direct-byok'; import { buildExperimentPromptCapture } from '@/lib/ai-gateway/experiments/persist'; import { isPublicIdExperimented } from '@/lib/ai-gateway/experiments/membership'; import { upstreamRequest } from '@/lib/ai-gateway/providers/upstream-request'; @@ -48,6 +49,7 @@ import { modelNotAllowedResponse, extractHeaderAndLimitLength, noFreeModelsAvailableResponse, + organizationAutoConfigurationResponse, temporarilyUnavailableResponse, usageLimitExceededResponse, wrapInSafeNextResponse, @@ -95,6 +97,7 @@ import { isKiloAutoModel, KILO_AUTO_FREE_MODEL, KILO_AUTO_EFFICIENT_MODEL, + ORG_AUTO_MODEL, } from '@/lib/ai-gateway/auto-model'; import { applyResolvedAutoModel } from '@/lib/ai-gateway/auto-model/resolution'; import { fetchEfficientAutoDecision } from '@/lib/ai-gateway/auto-routing-decision'; @@ -242,6 +245,13 @@ export async function POST(request: NextRequest): Promise ({ + organizationId: auth.organizationId, + settings: balanceAndSettings.settings, + plan: balanceAndSettings.plan, + }) + ); // Extract IP early (needed for free model routing fallback and rate limiting) const ipAddress = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); @@ -271,6 +281,7 @@ export async function POST(request: NextRequest): Promise res.user), @@ -319,6 +331,10 @@ export async function POST(request: NextRequest): Promise { expect(mockedGetEnhancedOpenRouterModels).not.toHaveBeenCalled(); }); + test('returns Organization Auto when it is configured as the organization default', async () => { + const user = await insertTestUser(); + const organization = await createOrganization('Test Org', user.id); + + mockedGetEnhancedOpenRouterModels.mockRejectedValue(new Error('should not be called')); + mockedGetAuthorizedOrgContext.mockResolvedValue({ + success: true, + data: { + user: { ...user, role: 'owner' }, + organization: { + ...organization, + plan: 'enterprise' as const, + settings: { + default_model: 'kilo-auto/org', + org_auto_model: { + routes: {}, + fallback_model: 'kilo-auto/balanced', + }, + }, + }, + }, + }); + + const response = await GET(new NextRequest('http://localhost:3000'), { + params: Promise.resolve({ id: organization.id }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.defaultModel).toBe('kilo-auto/org'); + }); + test('returns 409 when all available models are blocked by policy', async () => { const user = await insertTestUser(); const organization = await createOrganization('Test Org', user.id); diff --git a/apps/web/src/app/api/organizations/[id]/defaults/route.ts b/apps/web/src/app/api/organizations/[id]/defaults/route.ts index 90f6744af0..1d971ed64f 100644 --- a/apps/web/src/app/api/organizations/[id]/defaults/route.ts +++ b/apps/web/src/app/api/organizations/[id]/defaults/route.ts @@ -8,8 +8,9 @@ import { hasActiveModelRestrictions, } from '@/lib/model-allow.server'; import { getModelIdToProviderSlugsIndex } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server'; -import { KILO_AUTO_FREE_MODEL } from '@/lib/ai-gateway/auto-model'; +import { KILO_AUTO_FREE_MODEL, ORG_AUTO_MODEL } from '@/lib/ai-gateway/auto-model'; import { getEffectiveModelRestrictions } from '@/lib/organizations/model-restrictions'; +import { isOrganizationAutoConfigured } from '@/lib/organizations/organization-auto-model'; type DefaultsResponse = { defaultModel: string; @@ -67,8 +68,17 @@ export async function GET( return undefined; }; - // If organization has a default model set, validate it against allowed models - if (defaultModel && (defaultModel.endsWith('/*') || !(await isAllowed(defaultModel)))) { + // If organization has a default model set, validate it against allowed models. + // Organization Auto is a virtual organization-only default, so its eligibility + // is validated from persisted organization settings rather than provider policy. + if (defaultModel === ORG_AUTO_MODEL.id && !isOrganizationAutoConfigured(organization)) { + console.warn('organization_auto_invalid_default', { organizationId: organization.id }); + defaultModel = undefined; + } else if ( + defaultModel && + defaultModel !== ORG_AUTO_MODEL.id && + (defaultModel.endsWith('/*') || !(await isAllowed(defaultModel))) + ) { // Organization's configured default model is not permitted; fall back to a safe default. defaultModel = undefined; } diff --git a/apps/web/src/app/api/organizations/[id]/modes/route.test.ts b/apps/web/src/app/api/organizations/[id]/modes/route.test.ts index 1d0a38868d..dea9edaccb 100644 --- a/apps/web/src/app/api/organizations/[id]/modes/route.test.ts +++ b/apps/web/src/app/api/organizations/[id]/modes/route.test.ts @@ -11,7 +11,7 @@ const mockedGetAuthorizedOrgContext = jest.mocked(getAuthorizedOrgContext); const mockedGetAllOrganizationModes = jest.mocked(getAllOrganizationModes); describe('GET /api/organizations/[id]/modes', () => { - test('returns defaultModel as part of the additive mode payload', async () => { + test('returns the direct mode payload without Organization Auto route projection', async () => { mockedGetAuthorizedOrgContext.mockResolvedValue({ success: true, data: { @@ -30,7 +30,6 @@ describe('GET /api/organizations/[id]/modes', () => { config: { roleDefinition: 'You are a coding assistant', groups: ['read'], - defaultModel: 'openai/gpt-4o', }, }, ]); @@ -53,57 +52,10 @@ describe('GET /api/organizations/[id]/modes', () => { config: { roleDefinition: 'You are a coding assistant', groups: ['read'], - defaultModel: 'openai/gpt-4o', }, }, ], }); expect(mockedGetAllOrganizationModes).toHaveBeenCalledWith('org-1'); }); - - test('returns a legacy mode row without defaultModel unchanged', async () => { - mockedGetAuthorizedOrgContext.mockResolvedValue({ - success: true, - data: { - organization: { id: 'org-1' }, - }, - } as never); - mockedGetAllOrganizationModes.mockResolvedValue([ - { - id: 'mode-1', - organization_id: 'org-1', - name: 'Code', - slug: 'code', - created_by: 'user-1', - created_at: '2026-01-01T00:00:00.000Z', - updated_at: '2026-01-01T00:00:00.000Z', - config: { - roleDefinition: 'You are a coding assistant', - groups: ['read'], - }, - }, - ]); - - const response = await GET(new NextRequest('http://localhost:3000'), { - params: Promise.resolve({ id: 'org-1' }), - }); - - await expect(response.json()).resolves.toEqual({ - modes: [ - { - id: 'mode-1', - organization_id: 'org-1', - name: 'Code', - slug: 'code', - created_by: 'user-1', - created_at: '2026-01-01T00:00:00.000Z', - updated_at: '2026-01-01T00:00:00.000Z', - config: { - roleDefinition: 'You are a coding assistant', - groups: ['read'], - }, - }, - ], - }); - }); }); diff --git a/apps/web/src/app/api/organizations/[id]/modes/route.ts b/apps/web/src/app/api/organizations/[id]/modes/route.ts index 305d406e3b..5d483dded1 100644 --- a/apps/web/src/app/api/organizations/[id]/modes/route.ts +++ b/apps/web/src/app/api/organizations/[id]/modes/route.ts @@ -15,9 +15,8 @@ export async function GET( } const { organization } = data; - const modes = await getAllOrganizationModes(organization.id); return NextResponse.json({ - modes, + modes: await getAllOrganizationModes(organization.id), }); } diff --git a/apps/web/src/app/api/organizations/hooks.ts b/apps/web/src/app/api/organizations/hooks.ts index 2093d3affd..53be8d076a 100644 --- a/apps/web/src/app/api/organizations/hooks.ts +++ b/apps/web/src/app/api/organizations/hooks.ts @@ -173,28 +173,87 @@ export function useUpdateCompanyDomain() { ); } -export function useUpdateOrganizationSettings() { +const useInvalidateOrganizationDataAndDefaults = () => { const trpc = useTRPC(); const queryClient = useQueryClient(); + return function (_: unknown, { organizationId }: { organizationId: Organization['id'] }) { + void queryClient.invalidateQueries({ queryKey: trpc.organizations.pathKey() }); + void queryClient.invalidateQueries({ queryKey: ['organization-defaults', organizationId] }); + }; +}; + +export function useUpdateOrganizationSettings() { + const trpc = useTRPC(); + const onSuccess = useInvalidateOrganizationDataAndDefaults(); return useMutation( trpc.organizations.settings.updateAllowLists.mutationOptions({ + onSuccess, + }) + ); +} + +export function useUpdateDefaultModel() { + const trpc = useTRPC(); + const onSuccess = useInvalidateOrganizationDataAndDefaults(); + + return useMutation( + trpc.organizations.settings.updateDefaultModel.mutationOptions({ + onSuccess, + }) + ); +} + +export function useEnableOrganizationAuto() { + const trpc = useTRPC(); + const onSuccess = useInvalidateOrganizationDataAndDefaults(); + return useMutation( + trpc.organizations.settings.enableOrganizationAuto.mutationOptions({ + onSuccess, + }) + ); +} + +export function useDisableOrganizationAuto() { + const trpc = useTRPC(); + const onSuccess = useInvalidateOrganizationDataAndDefaults(); + return useMutation( + trpc.organizations.settings.disableOrganizationAuto.mutationOptions({ + onSuccess, + }) + ); +} + +export function useSetOrganizationAutoFallback() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + return useMutation( + trpc.organizations.settings.setOrganizationAutoFallback.mutationOptions({ onSuccess: () => { - // lazy-mode invalidate everything related to an org if settings change void queryClient.invalidateQueries({ queryKey: trpc.organizations.pathKey() }); }, }) ); } -export function useUpdateDefaultModel() { +export function useSetOrganizationAutoRoute() { const trpc = useTRPC(); const queryClient = useQueryClient(); + return useMutation( + trpc.organizations.settings.setOrganizationAutoRoute.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: trpc.organizations.pathKey() }); + }, + }) + ); +} +export function useClearOrganizationAutoRoute() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); return useMutation( - trpc.organizations.settings.updateDefaultModel.mutationOptions({ + trpc.organizations.settings.clearOrganizationAutoRoute.mutationOptions({ onSuccess: () => { - // lazy-mode invalidate everything related to an org if settings change void queryClient.invalidateQueries({ queryKey: trpc.organizations.pathKey() }); }, }) @@ -492,56 +551,61 @@ export function useOrganizationModeById(organizationId: string, modeId: string) ); } -export function useCreateOrganizationMode() { +function useInvalidateOrganizationModes() { const trpc = useTRPC(); const queryClient = useQueryClient(); + return async (_: unknown, variables: { organizationId: Organization['id']; modeId?: string }) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.organizations.modes.list.queryKey({ + organizationId: variables.organizationId, + }), + }), + queryClient.invalidateQueries({ + queryKey: trpc.organizations.withMembers.queryKey({ + organizationId: variables.organizationId, + }), + }), + ...(variables.modeId + ? [ + queryClient.invalidateQueries({ + queryKey: trpc.organizations.modes.getById.queryKey({ + organizationId: variables.organizationId, + modeId: variables.modeId, + }), + }), + ] + : []), + ]); + }; +} + +export function useCreateOrganizationMode() { + const trpc = useTRPC(); + const onSuccess = useInvalidateOrganizationModes(); return useMutation( trpc.organizations.modes.create.mutationOptions({ - onSuccess: (_, variables) => { - void queryClient.invalidateQueries({ - queryKey: trpc.organizations.modes.list.queryKey({ - organizationId: variables.organizationId, - }), - }); - }, + onSuccess, }) ); } export function useUpdateOrganizationMode() { const trpc = useTRPC(); - const queryClient = useQueryClient(); + const onSuccess = useInvalidateOrganizationModes(); return useMutation( trpc.organizations.modes.update.mutationOptions({ - onSuccess: (_, variables) => { - void queryClient.invalidateQueries({ - queryKey: trpc.organizations.modes.list.queryKey({ - organizationId: variables.organizationId, - }), - }); - void queryClient.invalidateQueries({ - queryKey: trpc.organizations.modes.getById.queryKey({ - organizationId: variables.organizationId, - modeId: variables.modeId, - }), - }); - }, + onSuccess, }) ); } export function useDeleteOrganizationMode() { const trpc = useTRPC(); - const queryClient = useQueryClient(); + const onSuccess = useInvalidateOrganizationModes(); return useMutation( trpc.organizations.modes.delete.mutationOptions({ - onSuccess: (_, variables) => { - void queryClient.invalidateQueries({ - queryKey: trpc.organizations.modes.list.queryKey({ - organizationId: variables.organizationId, - }), - }); - }, + onSuccess, }) ); } diff --git a/apps/web/src/components/models/CondensedProviderAndModelsList.tsx b/apps/web/src/components/models/CondensedProviderAndModelsList.tsx index 2bb1aa9cf3..8b3c815f73 100644 --- a/apps/web/src/components/models/CondensedProviderAndModelsList.tsx +++ b/apps/web/src/components/models/CondensedProviderAndModelsList.tsx @@ -97,7 +97,7 @@ export function CondensedProviderAndModelsList({

You don't have permission to change the default model. Contact your organization - owner to update this setting. + owner or billing manager to update this setting.

diff --git a/apps/web/src/components/organizations/OrganizationDashboard.tsx b/apps/web/src/components/organizations/OrganizationDashboard.tsx index 5b69d93365..2adc4ccb8f 100644 --- a/apps/web/src/components/organizations/OrganizationDashboard.tsx +++ b/apps/web/src/components/organizations/OrganizationDashboard.tsx @@ -149,7 +149,9 @@ export function OrganizationDashboard({ ) : ( )} diff --git a/apps/web/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx b/apps/web/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx index 4c9863ca4b..e643973348 100644 --- a/apps/web/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx +++ b/apps/web/src/components/organizations/OrganizationProvidersAndModelsConfigurationCard.tsx @@ -175,6 +175,7 @@ export function OrganizationProvidersAndModelsConfigurationCard({ organizationId={organizationId} organizationSettings={organizationData?.settings} currentDefaultModel={organizationData?.settings?.default_model} + organizationPlan={organizationData?.plan} /> void; onEditClick: (mode: DisplayMode) => void; isDefaultModelConfigEnabled: boolean; @@ -54,117 +63,135 @@ type ModesListProps = { function ModesList({ modes, readonly, + canManageRoutes, onDeleteClick, onEditClick, isDefaultModelConfigEnabled, }: ModesListProps) { return (
- {modes.map((mode, index) => ( - - - -
-
- {mode.name} -

- {mode.config.description} -

-
- {!readonly && ( -
- - {mode.isOverridden ? ( - - ) : ( - !mode.isDefault && ( + {mode.isOverridden ? ( - ) - )} -
- )} -
-
- -
- {isDefaultModelConfigEnabled && mode.config.defaultModel && ( -
- Default model - - {mode.config.defaultModel} - -
- )} - {mode.config?.groups && mode.config.groups.length > 0 && ( -
-

Available Tools

-
- {mode.config.groups.map((group, idx) => { - const groupName = Array.isArray(group) ? group[0] : group; - const groupConfig = Array.isArray(group) ? group[1] : null; - const hasRestriction = !!groupConfig; - const tooltipText = groupConfig - ? `Restricted file access: ${groupConfig.description || ''} (${groupConfig.fileRegex})`.trim() - : undefined; - - const badgeContent = ( - onDeleteClick(mode)} + disabled={lifecycleRouteLocked} + title={ + lifecycleRouteLocked + ? 'Route managers must delete a routed mode.' + : undefined + } + className="text-red-400 hover:text-red-500" > - {groupName} - {hasRestriction && ' *'} - - ); + + + ) + )} +
+ )} +
+ + +
+ {isDefaultModelConfigEnabled && mode.routeModel && ( +
+ + Organization Auto route + + + {mode.routeModel} + +
+ )} + {mode.config?.groups && mode.config.groups.length > 0 && ( +
+

Available Tools

+
+ {mode.config.groups.map((group, idx) => { + const groupName = Array.isArray(group) ? group[0] : group; + const groupConfig = Array.isArray(group) ? group[1] : null; + const hasRestriction = !!groupConfig; + const tooltipText = groupConfig + ? `Restricted file access: ${groupConfig.description || ''} (${groupConfig.fileRegex})`.trim() + : undefined; - return hasRestriction ? ( - - {badgeContent} - {tooltipText} - - ) : ( - badgeContent - ); - })} + const badgeContent = ( + + {groupName} + {hasRestriction && ' *'} + + ); + + return hasRestriction ? ( + + {badgeContent} + {tooltipText} + + ) : ( + badgeContent + ); + })} +
-
- )} -
-
-
-
- ))} + )} +
+ + + + ); + })} ); } -export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { +export function CustomModesLayout({ organizationId, role, isGlobalAdmin }: CustomModesLayoutProps) { const { data, isLoading, error } = useOrganizationModes(organizationId); const { data: organizationData } = useOrganizationWithMembers(organizationId); const deleteMutation = useDeleteOrganizationMode(); @@ -174,10 +201,12 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { const [drawerMode, setDrawerMode] = useState<'create' | 'edit'>('create'); const [editingMode, setEditingMode] = useState(null); const isReadOnly = useOrganizationReadOnly(organizationId); - const isDefaultModelFeatureEnabled = useFeatureFlagEnabled('org-default-model-config'); + const isDefaultModelFeatureEnabled = useFeatureFlagEnabled(ORGANIZATION_AUTO_MODEL_FLAG); const isDevelopment = process.env.NODE_ENV === 'development'; const isDefaultModelConfigEnabled = isDevelopment || isDefaultModelFeatureEnabled === true; - const canSetDefaultModel = organizationData?.plan === 'enterprise'; + const canMaintainRoutedMode = role === 'owner' || role === 'billing_manager' || isGlobalAdmin; + const canSetDefaultModel = + organizationData?.plan === 'enterprise' && isDefaultModelConfigEnabled && canMaintainRoutedMode; const readonly = isReadOnly; // Separate built-in modes and custom modes @@ -197,6 +226,9 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { ...customMode, isDefault: true, isOverridden: true, + routeModel: organizationData + ? getOrganizationAutoRoute(organizationData.settings, customMode.slug) + : undefined, }; } else { // This default mode is not overridden @@ -207,6 +239,9 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { slug: defaultMode.slug, name: defaultMode.name, config: defaultMode.config, + routeModel: organizationData + ? getOrganizationAutoRoute(organizationData.settings, defaultMode.slug) + : undefined, created_by: '', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), @@ -224,6 +259,9 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { ...mode, isDefault: false, isOverridden: false, + routeModel: organizationData + ? getOrganizationAutoRoute(organizationData.settings, mode.slug) + : undefined, })) .sort((a, b) => a.name.localeCompare(b.name)); @@ -231,7 +269,7 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { builtInModes: builtInDisplayModes, customModes: customDisplayModes, }; - }, [data?.modes, organizationId]); + }, [data?.modes, organizationData?.settings, organizationId]); const handleDelete = async () => { if (!modeToDelete) return; @@ -242,6 +280,7 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { await deleteMutation.mutateAsync({ organizationId, modeId: modeToDelete.id, + ...(modeToDelete.isDefault && modeToDelete.isOverridden ? { preserve_route: true } : {}), }); toast.success(`Mode "${modeToDelete.name}" ${action} successfully`); setDeleteDialogOpen(false); @@ -319,6 +358,7 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { organizationId={organizationId} modes={builtInModes} readonly={readonly} + canManageRoutes={canMaintainRoutedMode} onDeleteClick={openDeleteDialog} onEditClick={handleEditMode} isDefaultModelConfigEnabled={isDefaultModelConfigEnabled} @@ -333,6 +373,7 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { organizationId={organizationId} modes={customModes} readonly={readonly} + canManageRoutes={canMaintainRoutedMode} onDeleteClick={openDeleteDialog} onEditClick={handleEditMode} isDefaultModelConfigEnabled={isDefaultModelConfigEnabled} @@ -395,10 +436,7 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { footer={
-
} @@ -421,6 +459,7 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { defaultModeSlug={editingMode.isDefault ? editingMode.slug : undefined} isDefaultModelConfigEnabled={isDefaultModelConfigEnabled} canSetDefaultModel={canSetDefaultModel} + canMaintainRoutedMode={canMaintainRoutedMode} onSuccess={handleDrawerClose} onCancel={handleDrawerClose} /> diff --git a/apps/web/src/components/organizations/custom-modes/EditModeForm.test.ts b/apps/web/src/components/organizations/custom-modes/EditModeForm.test.ts index 089bc093f7..4d5e900ffa 100644 --- a/apps/web/src/components/organizations/custom-modes/EditModeForm.test.ts +++ b/apps/web/src/components/organizations/custom-modes/EditModeForm.test.ts @@ -3,21 +3,25 @@ import { matchesBuiltInModeState } from './EditModeForm'; import { DEFAULT_MODES } from './default-modes'; import type { ModeFormData } from './ModeForm'; -function codeModeForm(overrides: Partial = {}): ModeFormData { - const codeMode = DEFAULT_MODES.find(mode => mode.slug === 'code')!; +function builtInModeForm(slug: string, overrides: Partial = {}): ModeFormData { + const mode = DEFAULT_MODES.find(defaultMode => defaultMode.slug === slug)!; return { - name: codeMode.name, - slug: codeMode.slug, - roleDefinition: codeMode.config.roleDefinition || '', - description: codeMode.config.description || '', - whenToUse: codeMode.config.whenToUse || '', - groups: [...(codeMode.config.groups || [])], - customInstructions: codeMode.config.customInstructions || '', + name: mode.name, + slug: mode.slug, + roleDefinition: mode.config.roleDefinition || '', + description: mode.config.description || '', + whenToUse: mode.config.whenToUse || '', + groups: [...(mode.config.groups || [])], + customInstructions: mode.config.customInstructions || '', ...overrides, }; } +function codeModeForm(overrides: Partial = {}): ModeFormData { + return builtInModeForm('code', overrides); +} + describe('matchesBuiltInModeState', () => { test('returns true for a built-in mode state with reordered groups', () => { const formData = codeModeForm({ groups: [...codeModeForm().groups].reverse() }); @@ -25,6 +29,14 @@ describe('matchesBuiltInModeState', () => { expect(matchesBuiltInModeState(formData, 'code')).toBe(true); }); + test('returns true for a built-in mode state with reordered configured groups', () => { + const formData = builtInModeForm('architect', { + groups: [...builtInModeForm('architect').groups].reverse(), + }); + + expect(matchesBuiltInModeState(formData, 'architect')).toBe(true); + }); + test('returns false when another customization remains', () => { const formData = codeModeForm({ description: 'Customized description' }); diff --git a/apps/web/src/components/organizations/custom-modes/EditModeForm.tsx b/apps/web/src/components/organizations/custom-modes/EditModeForm.tsx index f4c9c17033..b60abdd10e 100644 --- a/apps/web/src/components/organizations/custom-modes/EditModeForm.tsx +++ b/apps/web/src/components/organizations/custom-modes/EditModeForm.tsx @@ -5,6 +5,7 @@ import { useUpdateOrganizationMode, useOrganizationModes, useDeleteOrganizationMode, + useOrganizationWithMembers, } from '@/app/api/organizations/hooks'; import { ModeForm, type ModeFormData } from './ModeForm'; import { LoadingCard } from '@/components/LoadingCard'; @@ -18,6 +19,7 @@ type EditModeFormProps = { defaultModeSlug?: string; isDefaultModelConfigEnabled?: boolean; canSetDefaultModel?: boolean; + canMaintainRoutedMode?: boolean; onSuccess?: () => void; onCancel?: () => void; }; @@ -31,7 +33,17 @@ function normalizeGroups(groups: unknown): string[] | undefined { return undefined; } - return groups.map(group => JSON.stringify(group)).sort(); + return groups + .map(group => { + if (Array.isArray(group) && group[0] === 'edit') { + return JSON.stringify([ + 'edit', + { fileRegex: group[1]?.fileRegex ?? '', description: group[1]?.description ?? '' }, + ]); + } + return JSON.stringify(group); + }) + .sort(); } export function matchesBuiltInModeState(formData: ModeFormData, defaultModeSlug: string): boolean { @@ -58,6 +70,7 @@ export function EditModeForm({ defaultModeSlug, isDefaultModelConfigEnabled = false, canSetDefaultModel = true, + canMaintainRoutedMode = true, onSuccess, onCancel, }: EditModeFormProps) { @@ -65,29 +78,33 @@ export function EditModeForm({ const { data: modesData } = useOrganizationModes(organizationId); const updateMutation = useUpdateOrganizationMode(); const deleteMutation = useDeleteOrganizationMode(); + const { data: organizationData, isLoading: isOrganizationLoading } = + useOrganizationWithMembers(organizationId); + const currentRouteModel = defaultModeSlug + ? organizationData?.settings.org_auto_model?.routes[defaultModeSlug] + : data?.mode + ? organizationData?.settings.org_auto_model?.routes[data.mode.slug] + : undefined; const handleSubmit = async (formData: ModeFormData) => { try { - if ( - defaultModeSlug && - !formData.defaultModel && - matchesBuiltInModeState(formData, defaultModeSlug) - ) { + const nextRouteModel = formData.defaultModel || undefined; + if (defaultModeSlug && matchesBuiltInModeState(formData, defaultModeSlug)) { + if (currentRouteModel && !canMaintainRoutedMode) { + toast.error('Route managers must revert a routed built-in mode.'); + return; + } await deleteMutation.mutateAsync({ organizationId, modeId, + preserve_route: true, + ...(nextRouteModel === currentRouteModel ? {} : { route_model: nextRouteModel ?? null }), }); toast.success(`Mode "${formData.name}" reverted successfully`); onSuccess?.(); return; } - const persistedDefaultModel = data?.mode?.config.defaultModel ?? ''; - const defaultModelUpdate = - formData.defaultModel === persistedDefaultModel - ? {} - : { defaultModel: formData.defaultModel || null }; - await updateMutation.mutateAsync({ organizationId, modeId, @@ -99,8 +116,8 @@ export function EditModeForm({ whenToUse: formData.whenToUse, groups: formData.groups as ('read' | 'edit' | 'browser' | 'command' | 'mcp')[], customInstructions: formData.customInstructions, - ...defaultModelUpdate, }, + ...(nextRouteModel === currentRouteModel ? {} : { route_model: nextRouteModel ?? null }), }); toast.success(`Mode "${formData.name}" updated successfully`); onSuccess?.(); @@ -130,14 +147,15 @@ export function EditModeForm({ null} /> ); } diff --git a/apps/web/src/components/organizations/custom-modes/ModeForm.tsx b/apps/web/src/components/organizations/custom-modes/ModeForm.tsx index 58b9e1e2d4..9d3a3aa2c5 100644 --- a/apps/web/src/components/organizations/custom-modes/ModeForm.tsx +++ b/apps/web/src/components/organizations/custom-modes/ModeForm.tsx @@ -1,7 +1,7 @@ 'use client'; import type { FormEvent } from 'react'; -import { useMemo, useState, useEffect } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -20,6 +20,8 @@ import type { EditGroupConfig } from '@/lib/organizations/organization-types'; import { Save, FileText } from 'lucide-react'; import { useModeTemplates } from './useModeTemplates'; import { useModelSelectorList } from '@/app/api/openrouter/hooks'; +import { isOrganizationAutoTargetModel } from '@/lib/organizations/organization-auto-model-shared'; +import { CUSTOM_LLM_PREFIX } from '@/lib/ai-gateway/model-utils'; const availableGroups = [ { value: 'read', label: 'Read Files' }, @@ -54,11 +56,13 @@ export type ModeFormData = z.infer; type ModeFormProps = { organizationId: string; mode?: OrganizationMode; + routeModel?: string; onSubmit: (data: ModeFormData) => Promise; isSubmitting: boolean; isEditingBuiltIn?: boolean; isDefaultModelConfigEnabled?: boolean; canSetDefaultModel?: boolean; + disableSlug?: boolean; existingModes?: OrganizationMode[]; onCancel?: () => void; renderButtons?: (props: { isDirty: boolean; isSubmitting: boolean }) => React.ReactNode; @@ -100,11 +104,13 @@ function denormalizeGroups( export function ModeForm({ organizationId, mode, + routeModel, onSubmit, isSubmitting, isEditingBuiltIn = false, isDefaultModelConfigEnabled = false, canSetDefaultModel = true, + disableSlug = false, existingModes = [], onCancel, renderButtons, @@ -116,7 +122,7 @@ export function ModeForm({ description: mode?.config?.description || '', whenToUse: mode?.config?.whenToUse || '', customInstructions: mode?.config?.customInstructions || '', - defaultModel: mode?.config?.defaultModel || '', + defaultModel: routeModel || '', }); const [selectedGroups, setSelectedGroups] = useState(() => { const { simpleGroups } = normalizeGroups(mode?.config?.groups || []); @@ -134,7 +140,7 @@ export function ModeForm({ description: mode?.config?.description || '', whenToUse: mode?.config?.whenToUse || '', customInstructions: mode?.config?.customInstructions || '', - defaultModel: mode?.config?.defaultModel || '', + defaultModel: routeModel || '', }); const [initialGroups, setInitialGroups] = useState(() => { const { simpleGroups } = normalizeGroups(mode?.config?.groups || []); @@ -145,6 +151,8 @@ export function ModeForm({ return editConfig || { fileRegex: '', description: '' }; }); const [selectedTemplate, setSelectedTemplate] = useState(''); + const routeFieldDirtyRef = useRef(false); + routeFieldDirtyRef.current = formData.defaultModel !== initialFormData.defaultModel; // Fetch mode templates const { data: templates, isLoading: templatesLoading } = useModeTemplates(); @@ -153,7 +161,19 @@ export function ModeForm({ isLoading: modelsLoading, error: modelsError, } = useModelSelectorList(organizationId, isDefaultModelConfigEnabled && canSetDefaultModel); - const modelOptions = useMemo(() => modelsData?.data || [], [modelsData?.data]); + const modelOptions = useMemo( + () => + (modelsData?.data || []).filter(model => { + if (model.id.startsWith(CUSTOM_LLM_PREFIX)) { + return false; + } + if (model.id.startsWith('kilo-auto/')) { + return isOrganizationAutoTargetModel(model.id); + } + return true; + }), + [modelsData?.data] + ); const hasCurrentDefaultModelOption = canSetDefaultModel && !!formData.defaultModel && @@ -165,7 +185,7 @@ export function ModeForm({ isDefaultModelConfigEnabled && (canSetDefaultModel || !!formData.defaultModel); const defaultModelChanged = formData.defaultModel !== initialFormData.defaultModel; - // Update form data when mode prop changes + // Re-seed the full form only when switching modes, not when settings refetch. useEffect(() => { if (mode) { const newFormData = { @@ -175,7 +195,7 @@ export function ModeForm({ description: mode.config?.description || '', whenToUse: mode.config?.whenToUse || '', customInstructions: mode.config?.customInstructions || '', - defaultModel: mode.config?.defaultModel || '', + defaultModel: routeModel || '', }; const { simpleGroups, editConfig } = normalizeGroups(mode.config?.groups || []); const newEditConfig = editConfig || { fileRegex: '', description: '' }; @@ -187,7 +207,16 @@ export function ModeForm({ setInitialGroups(simpleGroups); setInitialEditConfig(newEditConfig); } - }, [mode]); + }, [mode?.id]); + + useEffect(() => { + if (routeFieldDirtyRef.current) { + return; + } + const nextRouteModel = routeModel || ''; + setFormData(previous => ({ ...previous, defaultModel: nextRouteModel })); + setInitialFormData(previous => ({ ...previous, defaultModel: nextRouteModel })); + }, [routeModel]); // Check if form is dirty (has changes) const isDirty = @@ -198,7 +227,7 @@ export function ModeForm({ formData.whenToUse !== initialFormData.whenToUse || formData.customInstructions !== initialFormData.customInstructions || formData.defaultModel !== initialFormData.defaultModel || - JSON.stringify(selectedGroups.sort()) !== JSON.stringify(initialGroups.sort()) || + JSON.stringify([...selectedGroups].sort()) !== JSON.stringify([...initialGroups].sort()) || editGroupConfig.fileRegex !== initialEditConfig.fileRegex || editGroupConfig.description !== initialEditConfig.description; @@ -378,12 +407,14 @@ export function ModeForm({ value={formData.slug} onChange={e => setFormData(prev => ({ ...prev, slug: e.target.value }))} placeholder="e.g., code" - disabled={isSubmitting || isEditingBuiltIn} + disabled={isSubmitting || isEditingBuiltIn || disableSlug} />

{isEditingBuiltIn ? 'Built-in mode slugs cannot be changed' - : 'Unique identifier for this mode.'} + : disableSlug + ? 'Route managers must rename routed modes.' + : 'Unique identifier for this mode.'}

{errors.slug &&

{errors.slug}

} @@ -456,7 +487,7 @@ export function ModeForm({ {shouldShowDefaultModelControl && (
- +

{!canSetDefaultModel - ? 'This organization must be on Enterprise to set mode defaults. Existing defaults can still be cleared.' + ? 'Organization Auto routes are read-only for your role or plan.' : modelsLoading ? 'Loading organization-allowed models...' : modelsError ? 'Unable to load organization models.' : modelOptions.length === 0 ? 'No organization-allowed models are available.' - : 'Members can still override this locally in Kilo Code.'} + : 'Members can still override Organization Auto locally in Kilo Code.'}

{hasUnavailableDefaultModel && (

diff --git a/apps/web/src/components/organizations/custom-modes/NewModeForm.tsx b/apps/web/src/components/organizations/custom-modes/NewModeForm.tsx index 6f5cd9aed0..673eb2aea4 100644 --- a/apps/web/src/components/organizations/custom-modes/NewModeForm.tsx +++ b/apps/web/src/components/organizations/custom-modes/NewModeForm.tsx @@ -1,11 +1,19 @@ 'use client'; import { useSearchParams } from 'next/navigation'; -import { useCreateOrganizationMode, useOrganizationModes } from '@/app/api/organizations/hooks'; +import { + useClearOrganizationAutoRoute, + useCreateOrganizationMode, + useOrganizationModes, + useOrganizationWithMembers, + useSetOrganizationAutoRoute, +} from '@/app/api/organizations/hooks'; import { ModeForm, type ModeFormData } from './ModeForm'; +import { matchesBuiltInModeState } from './EditModeForm'; import { toast } from 'sonner'; import { DEFAULT_MODES } from './default-modes'; import { useMemo } from 'react'; +import { getOrganizationAutoRoute } from '@/lib/organizations/organization-auto-model-shared'; type NewModeFormProps = { organizationId: string; @@ -26,7 +34,10 @@ export function NewModeForm({ }: NewModeFormProps) { const searchParams = useSearchParams(); const createMutation = useCreateOrganizationMode(); + const setRouteMutation = useSetOrganizationAutoRoute(); + const clearRouteMutation = useClearOrganizationAutoRoute(); const { data: modesData } = useOrganizationModes(organizationId); + const { data: organizationData } = useOrganizationWithMembers(organizationId); // Check if we're editing a default mode (from prop or search params) const defaultModeSlug = propDefaultModeSlug || searchParams.get('defaultMode'); @@ -34,6 +45,9 @@ export function NewModeForm({ if (!defaultModeSlug) return undefined; return DEFAULT_MODES.find(m => m.slug === defaultModeSlug); }, [defaultModeSlug]); + const routeModel = defaultModeSlug + ? getOrganizationAutoRoute(organizationData?.settings, defaultModeSlug) + : undefined; // Convert default mode to the format expected by ModeForm const initialMode = useMemo(() => { @@ -50,8 +64,28 @@ export function NewModeForm({ }; }, [defaultMode, organizationId]); + const persistRoute = async (modeSlug: string, targetModelId: string | undefined) => { + if (targetModelId) { + await setRouteMutation.mutateAsync({ + organizationId, + mode_slug: modeSlug, + model_id: targetModelId, + }); + } else { + await clearRouteMutation.mutateAsync({ organizationId, mode_slug: modeSlug }); + } + }; + const handleSubmit = async (data: ModeFormData) => { try { + if (defaultModeSlug && matchesBuiltInModeState(data, defaultModeSlug)) { + await persistRoute(defaultModeSlug, data.defaultModel); + toast.success(`Mode "${data.name}" route updated successfully`); + onSuccess?.(); + return; + } + + const nextRouteModel = data.defaultModel || undefined; await createMutation.mutateAsync({ organizationId, name: data.name, @@ -62,8 +96,8 @@ export function NewModeForm({ whenToUse: data.whenToUse, groups: data.groups as ('read' | 'edit' | 'browser' | 'command' | 'mcp')[], customInstructions: data.customInstructions, - ...(data.defaultModel ? { defaultModel: data.defaultModel } : {}), }, + ...(nextRouteModel === routeModel ? {} : { route_model: nextRouteModel ?? null }), }); toast.success(`Mode "${data.name}" created successfully`); onSuccess?.(); @@ -78,14 +112,16 @@ export function NewModeForm({ null} /> ); } diff --git a/apps/web/src/components/organizations/providers-and-models/DefaultModelDialog.tsx b/apps/web/src/components/organizations/providers-and-models/DefaultModelDialog.tsx index 5434c68c21..3e1813d5f6 100644 --- a/apps/web/src/components/organizations/providers-and-models/DefaultModelDialog.tsx +++ b/apps/web/src/components/organizations/providers-and-models/DefaultModelDialog.tsx @@ -1,9 +1,13 @@ 'use client'; -import { useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { LockableContainer } from '../LockableContainer'; -import { useUpdateDefaultModel } from '@/app/api/organizations/hooks'; +import { + useDisableOrganizationAuto, + useEnableOrganizationAuto, + useSetOrganizationAutoFallback, + useUpdateDefaultModel, +} from '@/app/api/organizations/hooks'; import { useModelSelectorList } from '@/app/api/openrouter/hooks'; import { Button } from '@/components/ui/button'; import { @@ -24,6 +28,14 @@ import { import type { OrganizationSettings } from '@/lib/organizations/organization-types'; import { toast } from 'sonner'; import { Settings2 } from 'lucide-react'; +import { ORG_AUTO_MODEL } from '@/lib/ai-gateway/auto-model'; +import { + isOrganizationAutoTargetModel, + ORGANIZATION_AUTO_MODEL_FLAG, +} from '@/lib/organizations/organization-auto-model-shared'; +import { CUSTOM_LLM_PREFIX } from '@/lib/ai-gateway/model-utils'; +import { useFeatureFlagEnabled } from 'posthog-js/react'; +import Link from 'next/link'; type DefaultModelDialogProps = { open: boolean; @@ -31,6 +43,7 @@ type DefaultModelDialogProps = { organizationId: string; organizationSettings?: OrganizationSettings; currentDefaultModel?: string; + organizationPlan?: 'teams' | 'enterprise'; }; export function DefaultModelDialog({ @@ -39,15 +52,47 @@ export function DefaultModelDialog({ organizationId, organizationSettings, currentDefaultModel, + organizationPlan, }: DefaultModelDialogProps) { - const queryClient = useQueryClient(); const [selectedModel, setSelectedModel] = useState(''); + const [selectedFallbackModel, setSelectedFallbackModel] = useState(''); const { data: openRouterModels, isLoading: modelsLoading } = useModelSelectorList(organizationId); const updateDefaultModelMutation = useUpdateDefaultModel(); + const enableOrganizationAutoMutation = useEnableOrganizationAuto(); + const disableOrganizationAutoMutation = useDisableOrganizationAuto(); + const setOrganizationAutoFallbackMutation = useSetOrganizationAutoFallback(); const organizationDefaultModel = organizationSettings?.default_model; - const availableModels = openRouterModels?.data ?? []; + const organizationAutoFallbackModel = organizationSettings?.org_auto_model?.fallback_model; + const organizationAutoEnabled = organizationDefaultModel === ORG_AUTO_MODEL.id; + const organizationAutoFeatureEnabled = useFeatureFlagEnabled(ORGANIZATION_AUTO_MODEL_FLAG); + const isDevelopment = process.env.NODE_ENV === 'development'; + const canConfigureOrganizationAuto = + organizationPlan === 'enterprise' && (isDevelopment || organizationAutoFeatureEnabled === true); + const showOrganizationAutoSection = organizationAutoEnabled || canConfigureOrganizationAuto; + const availableModels = (openRouterModels?.data ?? []).filter( + model => model.id !== ORG_AUTO_MODEL.id + ); + useEffect(() => { + if (!open) { + setSelectedModel(''); + setSelectedFallbackModel(''); + } + }, [open]); + + const organizationAutoTargetModels = availableModels.filter(model => { + if (model.id.startsWith(CUSTOM_LLM_PREFIX)) { + return false; + } + if (model.id.startsWith('kilo-auto/')) { + return isOrganizationAutoTargetModel(model.id); + } + return true; + }); + const organizationAutoFallbackUnavailable = + !!organizationAutoFallbackModel && + !organizationAutoTargetModels.some(model => model.id === organizationAutoFallbackModel); const handleUpdateDefaultModel = async () => { if (!selectedModel) return; @@ -58,11 +103,6 @@ export function DefaultModelDialog({ default_model: selectedModel, }); - // Invalidate the defaults query to refresh the display - await queryClient.invalidateQueries({ - queryKey: ['organization-defaults', organizationId], - }); - setSelectedModel(''); onOpenChange(false); toast.success('Default model updated successfully'); @@ -80,10 +120,6 @@ export function DefaultModelDialog({ default_model: null, }); - await queryClient.invalidateQueries({ - queryKey: ['organization-defaults', organizationId], - }); - setSelectedModel(''); onOpenChange(false); toast.success('Default model cleared - will use global default'); @@ -93,6 +129,54 @@ export function DefaultModelDialog({ } }; + const handleEnableOrganizationAuto = async () => { + try { + await enableOrganizationAutoMutation.mutateAsync({ organizationId }); + setSelectedModel(''); + onOpenChange(false); + toast.success('Organization Auto enabled'); + } catch (error) { + console.error('Failed to enable Organization Auto:', error); + toast.error(error instanceof Error ? error.message : 'Failed to enable Organization Auto'); + } + }; + + const handleDisableOrganizationAuto = async () => { + if (!selectedModel) return; + + try { + await disableOrganizationAutoMutation.mutateAsync({ + organizationId, + replacement_model: selectedModel, + }); + setSelectedModel(''); + onOpenChange(false); + toast.success('Organization Auto disabled'); + } catch (error) { + console.error('Failed to disable Organization Auto:', error); + toast.error(error instanceof Error ? error.message : 'Failed to disable Organization Auto'); + } + }; + + const handleSetOrganizationAutoFallback = async () => { + const fallbackModel = selectedFallbackModel || organizationAutoFallbackModel; + if (!fallbackModel) return; + + try { + await setOrganizationAutoFallbackMutation.mutateAsync({ + organizationId, + model_id: fallbackModel, + }); + setSelectedFallbackModel(''); + toast.success('Organization Auto fallback updated'); + } catch (error) { + console.error('Failed to update Organization Auto fallback:', error); + toast.error( + error instanceof Error ? error.message : 'Failed to update Organization Auto fallback' + ); + } + }; + return (

@@ -129,8 +213,109 @@ export function DefaultModelDialog({
+ {showOrganizationAutoSection && ( +
+
+ +

+ Route the organization default by mode. Local model selections still override + it. +

+ {canConfigureOrganizationAuto && ( + onOpenChange(false)} + > + Configure mode routes + + )} +
+ {organizationAutoEnabled ? ( +

+ Enabled. Choose a replacement model below to disable it. +

+ ) : ( + + )} + {canConfigureOrganizationAuto && organizationSettings?.org_auto_model && ( +
+ +
+ + +
+ {organizationAutoFallbackUnavailable && ( +

+ This fallback is no longer available. Modes without explicit routes will + fail until you replace it. +

+ )} +
+ )} +
+ )} +
- +