From 0702a4e0d20f0703466385f149cb5c2b8f80e4d1 Mon Sep 17 00:00:00 2001 From: syn Date: Thu, 11 Jun 2026 13:46:48 -0500 Subject: [PATCH 1/4] feat(organizations): add mode default model contract --- .../organizations/[id]/modes/route.test.ts | 109 ++++++++++++ .../organizations/organization-modes.test.ts | 68 ++++++- .../lib/organizations/organization-modes.ts | 14 +- .../organization-modes-router.test.ts | 168 ++++++++++++++++++ .../organization-modes-router.ts | 124 ++++++++++++- packages/db/src/schema-types.ts | 1 + 6 files changed, 473 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/app/api/organizations/[id]/modes/route.test.ts 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 new file mode 100644 index 0000000000..1d0a38868d --- /dev/null +++ b/apps/web/src/app/api/organizations/[id]/modes/route.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from '@jest/globals'; +import { NextRequest, NextResponse } from 'next/server'; +import { GET } from './route'; +import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; +import { getAllOrganizationModes } from '@/lib/organizations/organization-modes'; + +jest.mock('@/lib/organizations/organization-auth'); +jest.mock('@/lib/organizations/organization-modes'); + +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 () => { + 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'], + defaultModel: 'openai/gpt-4o', + }, + }, + ]); + + const response = await GET(new NextRequest('http://localhost:3000'), { + params: Promise.resolve({ id: 'org-1' }), + }); + + expect(response).toBeInstanceOf(NextResponse); + 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'], + 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/lib/organizations/organization-modes.test.ts b/apps/web/src/lib/organizations/organization-modes.test.ts index 0f4d1cd725..fb48185441 100644 --- a/apps/web/src/lib/organizations/organization-modes.test.ts +++ b/apps/web/src/lib/organizations/organization-modes.test.ts @@ -3,7 +3,11 @@ import { db } from '@/lib/drizzle'; import { organizations } from '@kilocode/db/schema'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { createOrganization } from './organizations'; -import { createOrganizationMode, getAllOrganizationModes } from './organization-modes'; +import { + createOrganizationMode, + getAllOrganizationModes, + updateOrganizationMode, +} from './organization-modes'; describe('createOrganizationMode', () => { afterEach(async () => { @@ -134,6 +138,68 @@ describe('createOrganizationMode', () => { expect(mode).not.toBeNull(); expect(mode?.config).toEqual(config); }); + + test('should preserve an optional default model', async () => { + const user = await insertTestUser(); + const organization = await createOrganization('Test Org', user.id); + + const mode = await createOrganizationMode(organization.id, user.id, 'Code Mode', 'code', { + roleDefinition: 'You are a coding assistant', + groups: ['read', 'edit'], + defaultModel: 'openai/gpt-4o', + }); + + expect(mode?.config.defaultModel).toBe('openai/gpt-4o'); + }); +}); + +describe('updateOrganizationMode', () => { + afterEach(async () => { + // eslint-disable-next-line drizzle/enforce-delete-with-where + await db.delete(organizations); + }); + + test('should preserve existing config when updating only defaultModel', async () => { + const user = await insertTestUser(); + const organization = await createOrganization('Test Org', user.id); + const mode = await createOrganizationMode(organization.id, user.id, 'Code Mode', 'code', { + roleDefinition: 'You are a coding assistant', + description: 'Write code', + groups: ['read', 'edit'], + }); + + const updatedMode = await updateOrganizationMode(mode!.id, { + config: { defaultModel: 'openai/gpt-4o' }, + }); + + expect(updatedMode?.config).toEqual({ + roleDefinition: 'You are a coding assistant', + description: 'Write code', + groups: ['read', 'edit'], + defaultModel: 'openai/gpt-4o', + }); + }); + + test('should clear defaultModel without dropping the rest of config', async () => { + const user = await insertTestUser(); + const organization = await createOrganization('Test Org', user.id); + const mode = await createOrganizationMode(organization.id, user.id, 'Code Mode', 'code', { + roleDefinition: 'You are a coding assistant', + description: 'Write code', + groups: ['read', 'edit'], + defaultModel: 'openai/gpt-4o', + }); + + const updatedMode = await updateOrganizationMode(mode!.id, { + config: { defaultModel: undefined }, + }); + + expect(updatedMode?.config).toEqual({ + roleDefinition: 'You are a coding assistant', + description: 'Write code', + groups: ['read', 'edit'], + }); + }); }); describe('getAllOrganizationModes', () => { diff --git a/apps/web/src/lib/organizations/organization-modes.ts b/apps/web/src/lib/organizations/organization-modes.ts index 35f36fe0b6..5eb1591de4 100644 --- a/apps/web/src/lib/organizations/organization-modes.ts +++ b/apps/web/src/lib/organizations/organization-modes.ts @@ -79,7 +79,19 @@ export async function updateOrganizationMode( updateData.slug = updates.slug; } if (updates.config !== undefined) { - updateData.config = mergeToSatisfy(updates.config); + const [existingMode] = await db + .select() + .from(orgnaization_modes) + .where(eq(orgnaization_modes.id, modeId)); + + if (!existingMode) { + return null; + } + + updateData.config = mergeToSatisfy({ + ...mergeToSatisfy(existingMode.config), + ...updates.config, + }); } try { diff --git a/apps/web/src/routers/organizations/organization-modes-router.test.ts b/apps/web/src/routers/organizations/organization-modes-router.test.ts index 738e19e59a..94e993a8c7 100644 --- a/apps/web/src/routers/organizations/organization-modes-router.test.ts +++ b/apps/web/src/routers/organizations/organization-modes-router.test.ts @@ -126,6 +126,90 @@ describe('organization modes tRPC router', () => { }) ).rejects.toThrow(); }); + + it('should allow an organization mode default that is not denied', async () => { + const caller = await createCallerForUser(owner.id); + const organization = await createTestOrganization( + 'Allowed Default Model Org', + owner.id, + 0, + { model_deny_list: ['anthropic/claude-3-opus'] }, + false + ); + + const result = await caller.organizations.modes.create({ + organizationId: organization.id, + name: 'Code Mode', + slug: 'code', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + defaultModel: 'openai/gpt-4o', + }, + }); + + expect(result.mode.config.defaultModel).toBe('openai/gpt-4o'); + }); + + it('should reject an organization mode default that is denied', async () => { + const caller = await createCallerForUser(owner.id); + const organization = await createTestOrganization( + 'Denied Default Model Org', + owner.id, + 0, + { model_deny_list: ['openai/gpt-4o'] }, + false + ); + + await expect( + caller.organizations.modes.create({ + organizationId: organization.id, + name: 'Code Mode', + slug: 'code', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + defaultModel: 'openai/gpt-4o', + }, + }) + ).rejects.toThrow( + "Default model 'openai/gpt-4o' is not in the organization's allowed models list" + ); + }); + + it('should reject an empty organization mode default', async () => { + const caller = await createCallerForUser(owner.id); + + await expect( + caller.organizations.modes.create({ + organizationId: testOrganization.id, + name: 'Code Mode', + slug: 'empty-default-model', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + defaultModel: '', + }, + }) + ).rejects.toThrow(); + }); + + it('should reject a wildcard organization mode default', async () => { + const caller = await createCallerForUser(owner.id); + + await expect( + caller.organizations.modes.create({ + organizationId: testOrganization.id, + name: 'Code Mode', + slug: 'wildcard-default-model', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + defaultModel: 'openai/*', + }, + }) + ).rejects.toThrow("Default model 'openai/*' is not a concrete model identifier"); + }); }); describe('list procedure', () => { @@ -348,6 +432,90 @@ describe('organization modes tRPC router', () => { }) ).rejects.toThrow(); }); + + it('should reject a denied organization mode default on update', async () => { + const caller = await createCallerForUser(owner.id); + const organization = await createTestOrganization( + 'Denied Update Default Model Org', + owner.id, + 0, + { model_deny_list: ['openai/gpt-4o'] }, + false + ); + const created = await caller.organizations.modes.create({ + organizationId: organization.id, + name: 'Code Mode', + slug: 'code', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + }, + }); + + await expect( + caller.organizations.modes.update({ + organizationId: organization.id, + modeId: created.mode.id, + config: { + defaultModel: 'openai/gpt-4o', + }, + }) + ).rejects.toThrow( + "Default model 'openai/gpt-4o' is not in the organization's allowed models list" + ); + }); + + it('should reject a wildcard organization mode default on update', async () => { + const caller = await createCallerForUser(owner.id); + const created = await caller.organizations.modes.create({ + organizationId: testOrganization.id, + name: 'Code Mode', + slug: 'wildcard-update-default-model', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + }, + }); + + await expect( + caller.organizations.modes.update({ + organizationId: testOrganization.id, + modeId: created.mode.id, + config: { + defaultModel: 'openai/*', + }, + }) + ).rejects.toThrow("Default model 'openai/*' is not a concrete model identifier"); + }); + + it('should clear an organization mode default on update', async () => { + const caller = await createCallerForUser(owner.id); + const created = await caller.organizations.modes.create({ + organizationId: testOrganization.id, + name: 'Code Mode', + slug: 'clear-default-model', + config: { + roleDefinition: 'You are a coding assistant', + description: 'Write code', + groups: ['read'], + defaultModel: 'openai/gpt-4o', + }, + }); + + const result = await caller.organizations.modes.update({ + organizationId: testOrganization.id, + modeId: created.mode.id, + config: { + defaultModel: null, + }, + }); + + expect(result.mode.config).toEqual({ + roleDefinition: 'You are a coding assistant', + description: 'Write code', + groups: ['read'], + }); + }); }); describe('delete procedure', () => { diff --git a/apps/web/src/routers/organizations/organization-modes-router.ts b/apps/web/src/routers/organizations/organization-modes-router.ts index 17b7ef2965..71c824b006 100644 --- a/apps/web/src/routers/organizations/organization-modes-router.ts +++ b/apps/web/src/routers/organizations/organization-modes-router.ts @@ -13,10 +13,23 @@ import { updateOrganizationMode, deleteOrganizationMode, } from '@/lib/organizations/organization-modes'; -import { OrganizationModeConfigSchema } from '@/lib/organizations/organization-types'; +import { + OrganizationModeConfigSchema, + type OrganizationModeConfig, +} from '@/lib/organizations/organization-types'; import { createAuditLog } from '@/lib/organizations/organization-audit-logs'; import { getOrganizationById } from '@/lib/organizations/organizations'; import { successResult } from '@/lib/maybe-result'; +import { createAllowPredicateFromRestrictions } from '@/lib/model-allow.server'; +import { getEffectiveModelRestrictions } from '@/lib/organizations/model-restrictions'; + +const ModeConfigInputSchema = OrganizationModeConfigSchema.partial(); + +const ModeUpdateConfigInputSchema = ModeConfigInputSchema.extend({ + defaultModel: z.string().min(1, 'Default model cannot be empty').nullable().optional(), +}); + +type ModeUpdateConfigInput = z.infer; const CreateModeInputSchema = OrganizationIdInputSchema.extend({ name: z @@ -28,7 +41,7 @@ const CreateModeInputSchema = OrganizationIdInputSchema.extend({ .min(1, 'Mode slug is required') .max(50, 'Mode slug must be less than 50 characters') .regex(/^[a-z0-9-]+$/, 'Mode slug must contain only lowercase letters, numbers, and hyphens'), - config: OrganizationModeConfigSchema.partial().optional(), + config: ModeConfigInputSchema.optional(), }); const UpdateModeInputSchema = OrganizationIdInputSchema.extend({ @@ -40,7 +53,7 @@ const UpdateModeInputSchema = OrganizationIdInputSchema.extend({ .max(50) .regex(/^[a-z0-9-]+$/) .optional(), - config: OrganizationModeConfigSchema.partial().optional(), + config: ModeUpdateConfigInputSchema.optional(), }); const DeleteModeInputSchema = OrganizationIdInputSchema.extend({ @@ -51,6 +64,52 @@ const ModeIdInputSchema = OrganizationIdInputSchema.extend({ modeId: z.uuid(), }); +function normalizeModeConfig( + config: ModeUpdateConfigInput | undefined +): Partial | undefined { + if (!config) { + return undefined; + } + + const { defaultModel, ...rest } = config; + if (defaultModel === null) { + return { ...rest, defaultModel: undefined }; + } + if (defaultModel === undefined) { + return rest; + } + + return { ...rest, defaultModel }; +} + +async function validateDefaultModel( + organization: Awaited>, + config: ModeUpdateConfigInput | undefined +): Promise { + const defaultModel = config?.defaultModel; + if (!organization || defaultModel === undefined || defaultModel === null) { + return; + } + + if (defaultModel.endsWith('/*')) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Default model '${defaultModel}' is not a concrete model identifier`, + }); + } + + const isAllowed = createAllowPredicateFromRestrictions( + getEffectiveModelRestrictions(organization) + ); + + if (!(await isAllowed(defaultModel))) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Default model '${defaultModel}' is not in the organization's allowed models list`, + }); + } +} + export const organizationModesRouter = createTRPCRouter({ create: organizationMemberMutationProcedure .input(CreateModeInputSchema) @@ -65,7 +124,15 @@ export const organizationModesRouter = createTRPCRouter({ }); } - const mode = await createOrganizationMode(organizationId, ctx.user.id, name, slug, config); + await validateDefaultModel(organization, config); + + const mode = await createOrganizationMode( + organizationId, + ctx.user.id, + name, + slug, + normalizeModeConfig(config) + ); if (!mode) { throw new TRPCError({ @@ -123,7 +190,20 @@ export const organizationModesRouter = createTRPCRouter({ }); } - const mode = await updateOrganizationMode(modeId, updates); + const organization = await getOrganizationById(organizationId); + if (!organization) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Organization not found', + }); + } + + await validateDefaultModel(organization, updates.config); + + const mode = await updateOrganizationMode(modeId, { + ...updates, + config: normalizeModeConfig(updates.config), + }); if (!mode) { throw new TRPCError({ @@ -142,34 +222,60 @@ export const organizationModesRouter = createTRPCRouter({ if (updates.config) { const configChanges: string[] = []; - if (updates.config.roleDefinition !== existingMode.config.roleDefinition) { + if ( + 'roleDefinition' in updates.config && + updates.config.roleDefinition !== existingMode.config.roleDefinition + ) { const oldValue = existingMode.config.roleDefinition || '(empty)'; const newValue = updates.config.roleDefinition || '(empty)'; configChanges.push( `roleDefinition: "${oldValue.substring(0, 50)}${oldValue.length > 50 ? '...' : ''}" → "${newValue.substring(0, 50)}${newValue.length > 50 ? '...' : ''}"` ); } - if (updates.config.whenToUse !== existingMode.config.whenToUse) { + if ( + 'whenToUse' in updates.config && + updates.config.whenToUse !== existingMode.config.whenToUse + ) { const oldValue = existingMode.config.whenToUse || '(empty)'; const newValue = updates.config.whenToUse || '(empty)'; configChanges.push( `whenToUse: "${oldValue.substring(0, 50)}${oldValue.length > 50 ? '...' : ''}" → "${newValue.substring(0, 50)}${newValue.length > 50 ? '...' : ''}"` ); } - if (updates.config.description !== existingMode.config.description) { + if ( + 'description' in updates.config && + updates.config.description !== existingMode.config.description + ) { const oldValue = existingMode.config.description || '(empty)'; const newValue = updates.config.description || '(empty)'; configChanges.push( `description: "${oldValue.substring(0, 50)}${oldValue.length > 50 ? '...' : ''}" → "${newValue.substring(0, 50)}${newValue.length > 50 ? '...' : ''}"` ); } - if (updates.config.customInstructions !== existingMode.config.customInstructions) { + if ( + 'customInstructions' in updates.config && + updates.config.customInstructions !== existingMode.config.customInstructions + ) { const oldValue = existingMode.config.customInstructions || '(empty)'; const newValue = updates.config.customInstructions || '(empty)'; configChanges.push( `customInstructions: "${oldValue.substring(0, 50)}${oldValue.length > 50 ? '...' : ''}" → "${newValue.substring(0, 50)}${newValue.length > 50 ? '...' : ''}"` ); } + if ( + 'defaultModel' in updates.config && + updates.config.defaultModel !== existingMode.config.defaultModel + ) { + if (existingMode.config.defaultModel && updates.config.defaultModel) { + configChanges.push( + `defaultModel: "${existingMode.config.defaultModel}" → "${updates.config.defaultModel}"` + ); + } else if (updates.config.defaultModel) { + configChanges.push(`defaultModel: set to "${updates.config.defaultModel}"`); + } else if (existingMode.config.defaultModel) { + configChanges.push(`defaultModel: cleared "${existingMode.config.defaultModel}"`); + } + } if ( updates.config.groups !== undefined && existingMode.config.groups !== undefined && diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index dd6cd0bc76..0cda76a877 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -765,6 +765,7 @@ export const OrganizationModeConfigSchema = z.object({ description: z.string().optional(), customInstructions: z.string().optional(), groups: z.array(GroupEntrySchema), + defaultModel: z.string().min(1, 'Default model cannot be empty').optional(), }); export type OrganizationModeConfig = z.infer; From c3fcd6aee4fba369ff60e15822321070af6e8300 Mon Sep 17 00:00:00 2001 From: syn Date: Thu, 11 Jun 2026 16:12:41 -0500 Subject: [PATCH 2/4] feat(organizations): add gated mode default controls --- apps/web/src/app/api/openrouter/hooks.ts | 3 +- .../custom-modes/CustomModesLayout.tsx | 29 +++- .../custom-modes/EditModeForm.tsx | 61 ++++++- .../organizations/custom-modes/ModeForm.tsx | 99 ++++++++++- .../custom-modes/NewModeForm.tsx | 5 + .../organizations/organization-modes.test.ts | 20 ++- .../lib/organizations/organization-modes.ts | 15 +- .../organization-modes-router.test.ts | 157 ++++++++++++++++++ .../organization-modes-router.ts | 49 +++++- 9 files changed, 421 insertions(+), 17 deletions(-) diff --git a/apps/web/src/app/api/openrouter/hooks.ts b/apps/web/src/app/api/openrouter/hooks.ts index ca5997d724..7c54c1c237 100644 --- a/apps/web/src/app/api/openrouter/hooks.ts +++ b/apps/web/src/app/api/openrouter/hooks.ts @@ -100,9 +100,10 @@ export function useOpenRouterProviders() { }); } -export function useModelSelectorList(organizationId: string | undefined) { +export function useModelSelectorList(organizationId: string | undefined, enabled = true) { const query = useQuery({ queryKey: ['openrouter-models', organizationId], + enabled, queryFn: async (): Promise => { const response = await fetch( organizationId ? `/api/organizations/${organizationId}/models` : '/api/openrouter/models' diff --git a/apps/web/src/components/organizations/custom-modes/CustomModesLayout.tsx b/apps/web/src/components/organizations/custom-modes/CustomModesLayout.tsx index 63a405bd6b..9a4ee2b56a 100644 --- a/apps/web/src/components/organizations/custom-modes/CustomModesLayout.tsx +++ b/apps/web/src/components/organizations/custom-modes/CustomModesLayout.tsx @@ -27,6 +27,7 @@ import { ModeDrawer } from './ModeDrawer'; import { NewModeForm } from './NewModeForm'; import { EditModeForm } from './EditModeForm'; import { useOrganizationReadOnly } from '@/lib/organizations/use-organization-read-only'; +import { useFeatureFlagEnabled } from 'posthog-js/react'; type CustomModesLayoutProps = { organizationId: string; @@ -43,9 +44,16 @@ type ModesListProps = { readonly: boolean; onDeleteClick: (mode: DisplayMode) => void; onEditClick: (mode: DisplayMode) => void; + isDefaultModelConfigEnabled: boolean; }; -function ModesList({ modes, readonly, onDeleteClick, onEditClick }: ModesListProps) { +function ModesList({ + modes, + readonly, + onDeleteClick, + onEditClick, + isDefaultModelConfigEnabled, +}: ModesListProps) { return (
{modes.map((mode, index) => ( @@ -99,7 +107,15 @@ function ModesList({ modes, readonly, onDeleteClick, onEditClick }: ModesListPro
-
+
+ {isDefaultModelConfigEnabled && mode.config.defaultModel && ( +
+ Default model + + {mode.config.defaultModel} + +
+ )} {mode.config?.groups && mode.config.groups.length > 0 && (

Available Tools

@@ -153,7 +169,9 @@ 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 isDevelopment = process.env.NODE_ENV === 'development'; + const isDefaultModelConfigEnabled = isDevelopment || isDefaultModelFeatureEnabled === true; const readonly = isReadOnly; // Separate built-in modes and custom modes @@ -297,6 +315,7 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { readonly={readonly} onDeleteClick={openDeleteDialog} onEditClick={handleEditMode} + isDefaultModelConfigEnabled={isDefaultModelConfigEnabled} />
@@ -310,6 +329,7 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { readonly={readonly} onDeleteClick={openDeleteDialog} onEditClick={handleEditMode} + isDefaultModelConfigEnabled={isDefaultModelConfigEnabled} />
)} @@ -383,6 +403,7 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { defaultModeSlug={ editingMode?.isDefault && !editingMode?.isOverridden ? editingMode.slug : undefined } + isDefaultModelConfigEnabled={isDefaultModelConfigEnabled} onSuccess={handleDrawerClose} onCancel={handleDrawerClose} /> @@ -390,6 +411,8 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) { diff --git a/apps/web/src/components/organizations/custom-modes/EditModeForm.tsx b/apps/web/src/components/organizations/custom-modes/EditModeForm.tsx index 0ae0d1326a..4cc7e082f2 100644 --- a/apps/web/src/components/organizations/custom-modes/EditModeForm.tsx +++ b/apps/web/src/components/organizations/custom-modes/EditModeForm.tsx @@ -4,26 +4,79 @@ import { useOrganizationModeById, useUpdateOrganizationMode, useOrganizationModes, + useDeleteOrganizationMode, } from '@/app/api/organizations/hooks'; import { ModeForm, type ModeFormData } from './ModeForm'; import { LoadingCard } from '@/components/LoadingCard'; import { ErrorCard } from '@/components/ErrorCard'; import { toast } from 'sonner'; +import { DEFAULT_MODES } from './default-modes'; type EditModeFormProps = { organizationId: string; modeId: string; + defaultModeSlug?: string; + isDefaultModelConfigEnabled?: boolean; onSuccess?: () => void; onCancel?: () => void; }; -export function EditModeForm({ organizationId, modeId, onSuccess, onCancel }: EditModeFormProps) { +function normalizeOptionalValue(value: string | undefined): string | undefined { + return value || undefined; +} + +function matchesBuiltInModeState(formData: ModeFormData, defaultModeSlug: string): boolean { + const defaultMode = DEFAULT_MODES.find(mode => mode.slug === defaultModeSlug); + if (!defaultMode) { + return false; + } + + return ( + formData.name === defaultMode.name && + formData.slug === defaultMode.slug && + formData.roleDefinition === defaultMode.config.roleDefinition && + normalizeOptionalValue(formData.description) === defaultMode.config.description && + normalizeOptionalValue(formData.whenToUse) === defaultMode.config.whenToUse && + JSON.stringify(formData.groups) === JSON.stringify(defaultMode.config.groups) && + normalizeOptionalValue(formData.customInstructions) === defaultMode.config.customInstructions + ); +} + +export function EditModeForm({ + organizationId, + modeId, + defaultModeSlug, + isDefaultModelConfigEnabled = false, + onSuccess, + onCancel, +}: EditModeFormProps) { const { data, isLoading, error } = useOrganizationModeById(organizationId, modeId); const { data: modesData } = useOrganizationModes(organizationId); const updateMutation = useUpdateOrganizationMode(); + const deleteMutation = useDeleteOrganizationMode(); const handleSubmit = async (formData: ModeFormData) => { try { + if ( + defaultModeSlug && + !formData.defaultModel && + matchesBuiltInModeState(formData, defaultModeSlug) + ) { + await deleteMutation.mutateAsync({ + organizationId, + modeId, + }); + 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, @@ -35,6 +88,7 @@ export function EditModeForm({ organizationId, modeId, onSuccess, onCancel }: Ed whenToUse: formData.whenToUse, groups: formData.groups as ('read' | 'edit' | 'browser' | 'command' | 'mcp')[], customInstructions: formData.customInstructions, + ...defaultModelUpdate, }, }); toast.success(`Mode "${formData.name}" updated successfully`); @@ -63,9 +117,12 @@ export function EditModeForm({ organizationId, modeId, onSuccess, onCancel }: Ed return ( null} diff --git a/apps/web/src/components/organizations/custom-modes/ModeForm.tsx b/apps/web/src/components/organizations/custom-modes/ModeForm.tsx index 619445c9db..0741879d7e 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 { useState, useEffect } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -19,6 +19,7 @@ import type { OrganizationMode } from '@/lib/organizations/organization-modes'; 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'; const availableGroups = [ { value: 'read', label: 'Read Files' }, @@ -28,6 +29,8 @@ const availableGroups = [ { value: 'mcp', label: 'Use MCP' }, ] as const; +const noDefaultModelValue = '__no-mode-specific-default__'; + const modeFormSchema = z.object({ name: z .string() @@ -43,15 +46,18 @@ const modeFormSchema = z.object({ whenToUse: z.string().optional(), groups: z.any(), // Will be validated separately customInstructions: z.string().optional(), + defaultModel: z.string().min(1, 'Default model cannot be empty').optional(), }); export type ModeFormData = z.infer; type ModeFormProps = { + organizationId: string; mode?: OrganizationMode; onSubmit: (data: ModeFormData) => Promise; isSubmitting: boolean; isEditingBuiltIn?: boolean; + isDefaultModelConfigEnabled?: boolean; existingModes?: OrganizationMode[]; onCancel?: () => void; renderButtons?: (props: { isDirty: boolean; isSubmitting: boolean }) => React.ReactNode; @@ -91,10 +97,12 @@ function denormalizeGroups( } export function ModeForm({ + organizationId, mode, onSubmit, isSubmitting, isEditingBuiltIn = false, + isDefaultModelConfigEnabled = false, existingModes = [], onCancel, renderButtons, @@ -106,6 +114,7 @@ export function ModeForm({ description: mode?.config?.description || '', whenToUse: mode?.config?.whenToUse || '', customInstructions: mode?.config?.customInstructions || '', + defaultModel: mode?.config?.defaultModel || '', }); const [selectedGroups, setSelectedGroups] = useState(() => { const { simpleGroups } = normalizeGroups(mode?.config?.groups || []); @@ -123,6 +132,7 @@ export function ModeForm({ description: mode?.config?.description || '', whenToUse: mode?.config?.whenToUse || '', customInstructions: mode?.config?.customInstructions || '', + defaultModel: mode?.config?.defaultModel || '', }); const [initialGroups, setInitialGroups] = useState(() => { const { simpleGroups } = normalizeGroups(mode?.config?.groups || []); @@ -136,6 +146,17 @@ export function ModeForm({ // Fetch mode templates const { data: templates, isLoading: templatesLoading } = useModeTemplates(); + const { + data: modelsData, + isLoading: modelsLoading, + error: modelsError, + } = useModelSelectorList(organizationId, isDefaultModelConfigEnabled); + const modelOptions = useMemo(() => modelsData?.data || [], [modelsData?.data]); + const hasCurrentDefaultModelOption = + !!formData.defaultModel && modelOptions.some(model => model.id === formData.defaultModel); + const shouldRenderCurrentDefaultModel = !!formData.defaultModel && !hasCurrentDefaultModelOption; + const hasUnavailableDefaultModel = + shouldRenderCurrentDefaultModel && !modelsLoading && !modelsError; // Update form data when mode prop changes useEffect(() => { @@ -147,6 +168,7 @@ export function ModeForm({ description: mode.config?.description || '', whenToUse: mode.config?.whenToUse || '', customInstructions: mode.config?.customInstructions || '', + defaultModel: mode.config?.defaultModel || '', }; const { simpleGroups, editConfig } = normalizeGroups(mode.config?.groups || []); const newEditConfig = editConfig || { fileRegex: '', description: '' }; @@ -168,6 +190,7 @@ export function ModeForm({ formData.description !== initialFormData.description || formData.whenToUse !== initialFormData.whenToUse || formData.customInstructions !== initialFormData.customInstructions || + formData.defaultModel !== initialFormData.defaultModel || JSON.stringify(selectedGroups.sort()) !== JSON.stringify(initialGroups.sort()) || editGroupConfig.fileRegex !== initialEditConfig.fileRegex || editGroupConfig.description !== initialEditConfig.description; @@ -205,6 +228,7 @@ export function ModeForm({ description: template.config.description || '', whenToUse: template.config.whenToUse || '', customInstructions: template.config.customInstructions || '', + defaultModel: '', }; const { simpleGroups, editConfig } = normalizeGroups(template.config.groups || []); @@ -237,6 +261,10 @@ export function ModeForm({ newErrors.slug = `A mode with the slug "${formData.slug}" already exists`; } + if (isDefaultModelConfigEnabled && hasUnavailableDefaultModel) { + newErrors.defaultModel = 'Choose an allowed model or clear this value.'; + } + if (Object.keys(newErrors).length > 0) { setErrors(newErrors); return; @@ -249,6 +277,7 @@ export function ModeForm({ const result = modeFormSchema.safeParse({ ...formData, + defaultModel: formData.defaultModel || undefined, groups, }); @@ -418,6 +447,74 @@ export function ModeForm({

+ {isDefaultModelConfigEnabled && ( +
+ + +

+ {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.'} +

+ {hasUnavailableDefaultModel && ( +

+ Choose an allowed model or clear this value before saving. +

+ )} + {errors.defaultModel &&

{errors.defaultModel}

} +
+ )} + {/* Available Tools */}
diff --git a/apps/web/src/components/organizations/custom-modes/NewModeForm.tsx b/apps/web/src/components/organizations/custom-modes/NewModeForm.tsx index c81dc70d9e..d92dcd42f9 100644 --- a/apps/web/src/components/organizations/custom-modes/NewModeForm.tsx +++ b/apps/web/src/components/organizations/custom-modes/NewModeForm.tsx @@ -10,6 +10,7 @@ import { useMemo } from 'react'; type NewModeFormProps = { organizationId: string; defaultModeSlug?: string; + isDefaultModelConfigEnabled?: boolean; onSuccess?: () => void; onCancel?: () => void; }; @@ -17,6 +18,7 @@ type NewModeFormProps = { export function NewModeForm({ organizationId, defaultModeSlug: propDefaultModeSlug, + isDefaultModelConfigEnabled = false, onSuccess, onCancel, }: NewModeFormProps) { @@ -58,6 +60,7 @@ export function NewModeForm({ whenToUse: data.whenToUse, groups: data.groups as ('read' | 'edit' | 'browser' | 'command' | 'mcp')[], customInstructions: data.customInstructions, + ...(data.defaultModel ? { defaultModel: data.defaultModel } : {}), }, }); toast.success(`Mode "${data.name}" created successfully`); @@ -71,10 +74,12 @@ export function NewModeForm({ return ( null} diff --git a/apps/web/src/lib/organizations/organization-modes.test.ts b/apps/web/src/lib/organizations/organization-modes.test.ts index fb48185441..231ad18b7a 100644 --- a/apps/web/src/lib/organizations/organization-modes.test.ts +++ b/apps/web/src/lib/organizations/organization-modes.test.ts @@ -168,7 +168,7 @@ describe('updateOrganizationMode', () => { groups: ['read', 'edit'], }); - const updatedMode = await updateOrganizationMode(mode!.id, { + const updatedMode = await updateOrganizationMode(organization.id, mode!.id, { config: { defaultModel: 'openai/gpt-4o' }, }); @@ -190,7 +190,7 @@ describe('updateOrganizationMode', () => { defaultModel: 'openai/gpt-4o', }); - const updatedMode = await updateOrganizationMode(mode!.id, { + const updatedMode = await updateOrganizationMode(organization.id, mode!.id, { config: { defaultModel: undefined }, }); @@ -200,6 +200,22 @@ describe('updateOrganizationMode', () => { groups: ['read', 'edit'], }); }); + + test('should not update a mode through another organization', async () => { + const user = await insertTestUser(); + const organization = await createOrganization('Test Org', user.id); + const otherOrganization = await createOrganization('Other Org', user.id); + const mode = await createOrganizationMode(organization.id, user.id, 'Code Mode', 'code', { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + }); + + const updatedMode = await updateOrganizationMode(otherOrganization.id, mode!.id, { + config: { defaultModel: 'openai/gpt-4o' }, + }); + + expect(updatedMode).toBeNull(); + }); }); describe('getAllOrganizationModes', () => { diff --git a/apps/web/src/lib/organizations/organization-modes.ts b/apps/web/src/lib/organizations/organization-modes.ts index 5eb1591de4..7dd0141646 100644 --- a/apps/web/src/lib/organizations/organization-modes.ts +++ b/apps/web/src/lib/organizations/organization-modes.ts @@ -63,6 +63,7 @@ export async function getOrganizationModeById( } export async function updateOrganizationMode( + organizationId: string, modeId: string, updates: { name?: string; @@ -82,7 +83,12 @@ export async function updateOrganizationMode( const [existingMode] = await db .select() .from(orgnaization_modes) - .where(eq(orgnaization_modes.id, modeId)); + .where( + and( + eq(orgnaization_modes.id, modeId), + eq(orgnaization_modes.organization_id, organizationId) + ) + ); if (!existingMode) { return null; @@ -98,7 +104,12 @@ export async function updateOrganizationMode( const [mode] = await db .update(orgnaization_modes) .set(updateData) - .where(eq(orgnaization_modes.id, modeId)) + .where( + and( + eq(orgnaization_modes.id, modeId), + eq(orgnaization_modes.organization_id, organizationId) + ) + ) .returning(); return mode ? { ...mode, config: mergeToSatisfy(mode.config) } : null; diff --git a/apps/web/src/routers/organizations/organization-modes-router.test.ts b/apps/web/src/routers/organizations/organization-modes-router.test.ts index 94e993a8c7..d63fdfe5c2 100644 --- a/apps/web/src/routers/organizations/organization-modes-router.test.ts +++ b/apps/web/src/routers/organizations/organization-modes-router.test.ts @@ -6,11 +6,23 @@ import { getAllOrganizationModes } from '@/lib/organizations/organization-modes' import type { User, Organization } from '@kilocode/db/schema'; import { randomUUID } from 'crypto'; +jest.mock('@/lib/posthog-feature-flags', () => ({ + isReleaseToggleEnabled: jest.fn(async () => true), +})); + +const mockedIsReleaseToggleEnabled = jest.mocked( + jest.requireMock('@/lib/posthog-feature-flags').isReleaseToggleEnabled +); + let owner: User; let member: User; let testOrganization: Organization; describe('organization modes tRPC router', () => { + beforeEach(() => { + mockedIsReleaseToggleEnabled.mockResolvedValue(true); + }); + beforeAll(async () => { owner = await insertTestUser({ google_user_email: 'owner-modes@example.com', @@ -194,6 +206,56 @@ describe('organization modes tRPC router', () => { ).rejects.toThrow(); }); + it('should reject mode default writes when the release flag is disabled', async () => { + mockedIsReleaseToggleEnabled.mockResolvedValueOnce(false); + const caller = await createCallerForUser(owner.id); + + await expect( + caller.organizations.modes.create({ + organizationId: testOrganization.id, + name: 'Code Mode', + slug: 'flag-disabled-default-model', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + defaultModel: 'openai/gpt-4o', + }, + }) + ).rejects.toThrow('Mode default model configuration is not available'); + }); + + it('should allow mode default writes in development when the release flag is disabled', async () => { + mockedIsReleaseToggleEnabled.mockResolvedValue(false); + const replacedEnv = jest.replaceProperty(process, 'env', { + ...process.env, + NODE_ENV: 'development', + }); + const caller = await createCallerForUser(owner.id); + + try { + await expect( + caller.organizations.modes.create({ + organizationId: testOrganization.id, + name: 'Code Mode', + slug: 'development-default-model', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + defaultModel: 'openai/gpt-4o', + }, + }) + ).resolves.toMatchObject({ + mode: { + config: { + defaultModel: 'openai/gpt-4o', + }, + }, + }); + } finally { + replacedEnv.restore(); + } + }); + it('should reject a wildcard organization mode default', async () => { const caller = await createCallerForUser(owner.id); @@ -210,6 +272,23 @@ describe('organization modes tRPC router', () => { }) ).rejects.toThrow("Default model 'openai/*' is not a concrete model identifier"); }); + + it('should reject a wildcard organization mode default with a variant suffix', async () => { + const caller = await createCallerForUser(owner.id); + + await expect( + caller.organizations.modes.create({ + organizationId: testOrganization.id, + name: 'Code Mode', + slug: 'wildcard-variant-default-model', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + defaultModel: 'openai/*:free', + }, + }) + ).rejects.toThrow("Default model 'openai/*:free' is not a concrete model identifier"); + }); }); describe('list procedure', () => { @@ -488,6 +567,84 @@ describe('organization modes tRPC router', () => { ).rejects.toThrow("Default model 'openai/*' is not a concrete model identifier"); }); + it('should reject a wildcard organization mode default with a variant suffix on update', async () => { + const caller = await createCallerForUser(owner.id); + const created = await caller.organizations.modes.create({ + organizationId: testOrganization.id, + name: 'Code Mode', + slug: 'wildcard-variant-update-default-model', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + }, + }); + + await expect( + caller.organizations.modes.update({ + organizationId: testOrganization.id, + modeId: created.mode.id, + config: { + defaultModel: 'openai/*:free', + }, + }) + ).rejects.toThrow("Default model 'openai/*:free' is not a concrete model identifier"); + }); + + it('should reject clearing a mode default when the release flag is disabled', async () => { + const caller = await createCallerForUser(owner.id); + const created = await caller.organizations.modes.create({ + organizationId: testOrganization.id, + name: 'Code Mode', + slug: 'flag-disabled-clear-default-model', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + defaultModel: 'openai/gpt-4o', + }, + }); + mockedIsReleaseToggleEnabled.mockResolvedValueOnce(false); + + await expect( + caller.organizations.modes.update({ + organizationId: testOrganization.id, + modeId: created.mode.id, + config: { + defaultModel: null, + }, + }) + ).rejects.toThrow('Mode default model configuration is not available'); + }); + + it('should allow ordinary mode edits when the release flag is disabled', async () => { + mockedIsReleaseToggleEnabled.mockResolvedValue(false); + const caller = await createCallerForUser(owner.id); + const created = await caller.organizations.modes.create({ + organizationId: testOrganization.id, + name: 'Code Mode', + slug: 'flag-disabled-normal-update', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + }, + }); + + await expect( + caller.organizations.modes.update({ + organizationId: testOrganization.id, + modeId: created.mode.id, + config: { + description: 'Updated description', + }, + }) + ).resolves.toMatchObject({ + mode: { + config: { + description: 'Updated description', + }, + }, + }); + }); + it('should clear an organization mode default on update', async () => { const caller = await createCallerForUser(owner.id); const created = await caller.organizations.modes.create({ diff --git a/apps/web/src/routers/organizations/organization-modes-router.ts b/apps/web/src/routers/organizations/organization-modes-router.ts index 71c824b006..3eb7e2fe50 100644 --- a/apps/web/src/routers/organizations/organization-modes-router.ts +++ b/apps/web/src/routers/organizations/organization-modes-router.ts @@ -22,6 +22,10 @@ import { getOrganizationById } from '@/lib/organizations/organizations'; import { successResult } from '@/lib/maybe-result'; import { createAllowPredicateFromRestrictions } from '@/lib/model-allow.server'; import { getEffectiveModelRestrictions } from '@/lib/organizations/model-restrictions'; +import { normalizeModelId } from '@/lib/ai-gateway/model-utils'; +import { isReleaseToggleEnabled } from '@/lib/posthog-feature-flags'; + +const ORGANIZATION_MODE_DEFAULT_MODEL_FLAG = 'org-default-model-config'; const ModeConfigInputSchema = OrganizationModeConfigSchema.partial(); @@ -30,6 +34,9 @@ const ModeUpdateConfigInputSchema = ModeConfigInputSchema.extend({ }); type ModeUpdateConfigInput = z.infer; +type DefaultModelConfig = { + defaultModel?: string | null; +}; const CreateModeInputSchema = OrganizationIdInputSchema.extend({ name: z @@ -64,6 +71,29 @@ const ModeIdInputSchema = OrganizationIdInputSchema.extend({ modeId: z.uuid(), }); +function hasDefaultModelUpdate(config: ModeUpdateConfigInput | undefined): boolean { + return !!config && Object.prototype.hasOwnProperty.call(config, 'defaultModel'); +} + +async function ensureDefaultModelConfigEnabled( + userId: string, + config: ModeUpdateConfigInput | undefined +): Promise { + if (!hasDefaultModelUpdate(config)) { + return; + } + + if ( + process.env.NODE_ENV !== 'development' && + !(await isReleaseToggleEnabled(ORGANIZATION_MODE_DEFAULT_MODEL_FLAG, userId)) + ) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Mode default model configuration is not available', + }); + } +} + function normalizeModeConfig( config: ModeUpdateConfigInput | undefined ): Partial | undefined { @@ -84,14 +114,15 @@ function normalizeModeConfig( async function validateDefaultModel( organization: Awaited>, - config: ModeUpdateConfigInput | undefined + config: DefaultModelConfig | undefined ): Promise { const defaultModel = config?.defaultModel; if (!organization || defaultModel === undefined || defaultModel === null) { return; } - if (defaultModel.endsWith('/*')) { + const normalizedDefaultModel = normalizeModelId(defaultModel); + if (normalizedDefaultModel.endsWith('/*')) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Default model '${defaultModel}' is not a concrete model identifier`, @@ -102,7 +133,7 @@ async function validateDefaultModel( getEffectiveModelRestrictions(organization) ); - if (!(await isAllowed(defaultModel))) { + if (!(await isAllowed(normalizedDefaultModel))) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Default model '${defaultModel}' is not in the organization's allowed models list`, @@ -124,6 +155,7 @@ export const organizationModesRouter = createTRPCRouter({ }); } + await ensureDefaultModelConfigEnabled(ctx.user.id, config); await validateDefaultModel(organization, config); const mode = await createOrganizationMode( @@ -198,11 +230,16 @@ export const organizationModesRouter = createTRPCRouter({ }); } - await validateDefaultModel(organization, updates.config); + await ensureDefaultModelConfigEnabled(ctx.user.id, updates.config); + const normalizedConfig = normalizeModeConfig(updates.config); + const effectiveConfig = normalizedConfig + ? { ...existingMode.config, ...normalizedConfig } + : existingMode.config; + await validateDefaultModel(organization, effectiveConfig); - const mode = await updateOrganizationMode(modeId, { + const mode = await updateOrganizationMode(organizationId, modeId, { ...updates, - config: normalizeModeConfig(updates.config), + config: normalizedConfig, }); if (!mode) { From cb49716a7d9044881e78962034a4d6c893f3a44c Mon Sep 17 00:00:00 2001 From: syn Date: Thu, 11 Jun 2026 17:04:38 -0500 Subject: [PATCH 3/4] fix(organizations): gate mode defaults by plan --- .../organization-modes-router.test.ts | 140 ++++++++++++++++++ .../organization-modes-router.ts | 39 ++++- 2 files changed, 172 insertions(+), 7 deletions(-) diff --git a/apps/web/src/routers/organizations/organization-modes-router.test.ts b/apps/web/src/routers/organizations/organization-modes-router.test.ts index d63fdfe5c2..748132366e 100644 --- a/apps/web/src/routers/organizations/organization-modes-router.test.ts +++ b/apps/web/src/routers/organizations/organization-modes-router.test.ts @@ -3,7 +3,10 @@ import { insertTestUser } from '@/tests/helpers/user.helper'; import { createTestOrganization } from '@/tests/helpers/organization.helper'; import { addUserToOrganization } from '@/lib/organizations/organizations'; import { getAllOrganizationModes } from '@/lib/organizations/organization-modes'; +import { db } from '@/lib/drizzle'; +import { organizations } from '@kilocode/db/schema'; import type { User, Organization } from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; import { randomUUID } from 'crypto'; jest.mock('@/lib/posthog-feature-flags', () => ({ @@ -163,6 +166,30 @@ describe('organization modes tRPC router', () => { expect(result.mode.config.defaultModel).toBe('openai/gpt-4o'); }); + it('should reject an organization mode default for a non-enterprise organization', async () => { + const caller = await createCallerForUser(owner.id); + const organization = await createTestOrganization( + 'Teams Default Model Org', + owner.id, + 0, + {}, + true + ); + + await expect( + caller.organizations.modes.create({ + organizationId: organization.id, + name: 'Code Mode', + slug: 'code', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + defaultModel: 'openai/gpt-4o', + }, + }) + ).rejects.toThrow('Model access configuration is not available for this organization.'); + }); + it('should reject an organization mode default that is denied', async () => { const caller = await createCallerForUser(owner.id); const organization = await createTestOrganization( @@ -512,6 +539,36 @@ describe('organization modes tRPC router', () => { ).rejects.toThrow(); }); + it('should reject an organization mode default on update for a non-enterprise organization', async () => { + const caller = await createCallerForUser(owner.id); + const organization = await createTestOrganization( + 'Teams Update Default Model Org', + owner.id, + 0, + {}, + true + ); + const created = await caller.organizations.modes.create({ + organizationId: organization.id, + name: 'Code Mode', + slug: 'code', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + }, + }); + + await expect( + caller.organizations.modes.update({ + organizationId: organization.id, + modeId: created.mode.id, + config: { + defaultModel: 'openai/gpt-4o', + }, + }) + ).rejects.toThrow('Model access configuration is not available for this organization.'); + }); + it('should reject a denied organization mode default on update', async () => { const caller = await createCallerForUser(owner.id); const organization = await createTestOrganization( @@ -590,6 +647,89 @@ describe('organization modes tRPC router', () => { ).rejects.toThrow("Default model 'openai/*:free' is not a concrete model identifier"); }); + it('should allow unrelated mode edits after a stored default becomes denied', async () => { + const caller = await createCallerForUser(owner.id); + const organization = await createTestOrganization( + 'Stale Default Model Org', + owner.id, + 0, + {}, + false + ); + const created = await caller.organizations.modes.create({ + organizationId: organization.id, + name: 'Code Mode', + slug: 'stale-default-model', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + defaultModel: 'openai/gpt-4o', + }, + }); + await db + .update(organizations) + .set({ settings: { model_deny_list: ['openai/gpt-4o'] } }) + .where(eq(organizations.id, organization.id)); + + await expect( + caller.organizations.modes.update({ + organizationId: organization.id, + modeId: created.mode.id, + config: { + description: 'Updated description', + }, + }) + ).resolves.toMatchObject({ + mode: { + config: { + description: 'Updated description', + defaultModel: 'openai/gpt-4o', + }, + }, + }); + }); + + it('should allow clearing a mode default after an enterprise organization downgrades', async () => { + const caller = await createCallerForUser(owner.id); + const organization = await createTestOrganization( + 'Downgraded Default Model Org', + owner.id, + 0, + {}, + false + ); + const created = await caller.organizations.modes.create({ + organizationId: organization.id, + name: 'Code Mode', + slug: 'downgraded-clear-default-model', + config: { + roleDefinition: 'You are a coding assistant', + groups: ['read'], + defaultModel: 'openai/gpt-4o', + }, + }); + await db + .update(organizations) + .set({ plan: 'teams' }) + .where(eq(organizations.id, organization.id)); + + await expect( + caller.organizations.modes.update({ + organizationId: organization.id, + modeId: created.mode.id, + config: { + defaultModel: null, + }, + }) + ).resolves.toMatchObject({ + mode: { + config: { + roleDefinition: 'You are a coding assistant', + }, + }, + }); + }); + it('should reject clearing a mode default when the release flag is disabled', async () => { const caller = await createCallerForUser(owner.id); const created = await caller.organizations.modes.create({ diff --git a/apps/web/src/routers/organizations/organization-modes-router.ts b/apps/web/src/routers/organizations/organization-modes-router.ts index 3eb7e2fe50..d2003267c3 100644 --- a/apps/web/src/routers/organizations/organization-modes-router.ts +++ b/apps/web/src/routers/organizations/organization-modes-router.ts @@ -71,13 +71,35 @@ const ModeIdInputSchema = OrganizationIdInputSchema.extend({ modeId: z.uuid(), }); -function hasDefaultModelUpdate(config: ModeUpdateConfigInput | undefined): boolean { +function hasDefaultModelUpdate(config: DefaultModelConfig | undefined): boolean { return !!config && Object.prototype.hasOwnProperty.call(config, 'defaultModel'); } +function hasDefaultModelValue( + config: DefaultModelConfig | undefined +): config is DefaultModelConfig & { defaultModel: string } { + return !!config && hasDefaultModelUpdate(config) && typeof config.defaultModel === 'string'; +} + +function ensureDefaultModelCanBeSet( + organization: NonNullable>>, + config: DefaultModelConfig | undefined +): void { + if (!hasDefaultModelValue(config)) { + return; + } + + if (organization.plan !== 'enterprise') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Model access configuration is not available for this organization.', + }); + } +} + async function ensureDefaultModelConfigEnabled( userId: string, - config: ModeUpdateConfigInput | undefined + config: DefaultModelConfig | undefined ): Promise { if (!hasDefaultModelUpdate(config)) { return; @@ -155,8 +177,11 @@ export const organizationModesRouter = createTRPCRouter({ }); } + ensureDefaultModelCanBeSet(organization, config); await ensureDefaultModelConfigEnabled(ctx.user.id, config); - await validateDefaultModel(organization, config); + if (hasDefaultModelValue(config)) { + await validateDefaultModel(organization, config); + } const mode = await createOrganizationMode( organizationId, @@ -230,12 +255,12 @@ export const organizationModesRouter = createTRPCRouter({ }); } + ensureDefaultModelCanBeSet(organization, updates.config); await ensureDefaultModelConfigEnabled(ctx.user.id, updates.config); + if (hasDefaultModelValue(updates.config)) { + await validateDefaultModel(organization, updates.config); + } const normalizedConfig = normalizeModeConfig(updates.config); - const effectiveConfig = normalizedConfig - ? { ...existingMode.config, ...normalizedConfig } - : existingMode.config; - await validateDefaultModel(organization, effectiveConfig); const mode = await updateOrganizationMode(organizationId, modeId, { ...updates, From 166e95acc62eaf4d35566f80ee9cdeab162b7575 Mon Sep 17 00:00:00 2001 From: syn Date: Fri, 12 Jun 2026 09:25:42 -0500 Subject: [PATCH 4/4] fix(organizations): normalize mode default review paths --- .../custom-modes/EditModeForm.tsx | 11 ++++- .../organization-modes-router.ts | 44 +++++++++---------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/organizations/custom-modes/EditModeForm.tsx b/apps/web/src/components/organizations/custom-modes/EditModeForm.tsx index 4cc7e082f2..4f09df3f73 100644 --- a/apps/web/src/components/organizations/custom-modes/EditModeForm.tsx +++ b/apps/web/src/components/organizations/custom-modes/EditModeForm.tsx @@ -25,6 +25,14 @@ function normalizeOptionalValue(value: string | undefined): string | undefined { return value || undefined; } +function normalizeGroups(groups: unknown): string[] | undefined { + if (!Array.isArray(groups)) { + return undefined; + } + + return groups.map(group => JSON.stringify(group)).sort(); +} + function matchesBuiltInModeState(formData: ModeFormData, defaultModeSlug: string): boolean { const defaultMode = DEFAULT_MODES.find(mode => mode.slug === defaultModeSlug); if (!defaultMode) { @@ -37,7 +45,8 @@ function matchesBuiltInModeState(formData: ModeFormData, defaultModeSlug: string formData.roleDefinition === defaultMode.config.roleDefinition && normalizeOptionalValue(formData.description) === defaultMode.config.description && normalizeOptionalValue(formData.whenToUse) === defaultMode.config.whenToUse && - JSON.stringify(formData.groups) === JSON.stringify(defaultMode.config.groups) && + JSON.stringify(normalizeGroups(formData.groups)) === + JSON.stringify(normalizeGroups(defaultMode.config.groups)) && normalizeOptionalValue(formData.customInstructions) === defaultMode.config.customInstructions ); } diff --git a/apps/web/src/routers/organizations/organization-modes-router.ts b/apps/web/src/routers/organizations/organization-modes-router.ts index d2003267c3..8fb2e58101 100644 --- a/apps/web/src/routers/organizations/organization-modes-router.ts +++ b/apps/web/src/routers/organizations/organization-modes-router.ts @@ -282,69 +282,67 @@ export const organizationModesRouter = createTRPCRouter({ changes.push(`slug: "${existingMode.slug}" → "${updates.slug}"`); } if (updates.config) { + const auditConfig = normalizedConfig ?? updates.config; const configChanges: string[] = []; if ( - 'roleDefinition' in updates.config && - updates.config.roleDefinition !== existingMode.config.roleDefinition + 'roleDefinition' in auditConfig && + auditConfig.roleDefinition !== existingMode.config.roleDefinition ) { const oldValue = existingMode.config.roleDefinition || '(empty)'; - const newValue = updates.config.roleDefinition || '(empty)'; + const newValue = auditConfig.roleDefinition || '(empty)'; configChanges.push( `roleDefinition: "${oldValue.substring(0, 50)}${oldValue.length > 50 ? '...' : ''}" → "${newValue.substring(0, 50)}${newValue.length > 50 ? '...' : ''}"` ); } - if ( - 'whenToUse' in updates.config && - updates.config.whenToUse !== existingMode.config.whenToUse - ) { + if ('whenToUse' in auditConfig && auditConfig.whenToUse !== existingMode.config.whenToUse) { const oldValue = existingMode.config.whenToUse || '(empty)'; - const newValue = updates.config.whenToUse || '(empty)'; + const newValue = auditConfig.whenToUse || '(empty)'; configChanges.push( `whenToUse: "${oldValue.substring(0, 50)}${oldValue.length > 50 ? '...' : ''}" → "${newValue.substring(0, 50)}${newValue.length > 50 ? '...' : ''}"` ); } if ( - 'description' in updates.config && - updates.config.description !== existingMode.config.description + 'description' in auditConfig && + auditConfig.description !== existingMode.config.description ) { const oldValue = existingMode.config.description || '(empty)'; - const newValue = updates.config.description || '(empty)'; + const newValue = auditConfig.description || '(empty)'; configChanges.push( `description: "${oldValue.substring(0, 50)}${oldValue.length > 50 ? '...' : ''}" → "${newValue.substring(0, 50)}${newValue.length > 50 ? '...' : ''}"` ); } if ( - 'customInstructions' in updates.config && - updates.config.customInstructions !== existingMode.config.customInstructions + 'customInstructions' in auditConfig && + auditConfig.customInstructions !== existingMode.config.customInstructions ) { const oldValue = existingMode.config.customInstructions || '(empty)'; - const newValue = updates.config.customInstructions || '(empty)'; + const newValue = auditConfig.customInstructions || '(empty)'; configChanges.push( `customInstructions: "${oldValue.substring(0, 50)}${oldValue.length > 50 ? '...' : ''}" → "${newValue.substring(0, 50)}${newValue.length > 50 ? '...' : ''}"` ); } if ( - 'defaultModel' in updates.config && - updates.config.defaultModel !== existingMode.config.defaultModel + 'defaultModel' in auditConfig && + auditConfig.defaultModel !== existingMode.config.defaultModel ) { - if (existingMode.config.defaultModel && updates.config.defaultModel) { + if (existingMode.config.defaultModel && auditConfig.defaultModel) { configChanges.push( - `defaultModel: "${existingMode.config.defaultModel}" → "${updates.config.defaultModel}"` + `defaultModel: "${existingMode.config.defaultModel}" → "${auditConfig.defaultModel}"` ); - } else if (updates.config.defaultModel) { - configChanges.push(`defaultModel: set to "${updates.config.defaultModel}"`); + } else if (auditConfig.defaultModel) { + configChanges.push(`defaultModel: set to "${auditConfig.defaultModel}"`); } else if (existingMode.config.defaultModel) { configChanges.push(`defaultModel: cleared "${existingMode.config.defaultModel}"`); } } if ( - updates.config.groups !== undefined && + auditConfig.groups !== undefined && existingMode.config.groups !== undefined && - JSON.stringify(updates.config.groups) !== JSON.stringify(existingMode.config.groups) + JSON.stringify(auditConfig.groups) !== JSON.stringify(existingMode.config.groups) ) { const oldValue = JSON.stringify(existingMode.config.groups); - const newValue = JSON.stringify(updates.config.groups); + const newValue = JSON.stringify(auditConfig.groups); configChanges.push( `groups: ${oldValue.substring(0, 50)}${oldValue.length > 50 ? '...' : ''} → ${newValue.substring(0, 50)}${newValue.length > 50 ? '...' : ''}` );