From 4032055389cccdf75bd05648817d1d81430795a7 Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Sat, 11 Apr 2026 03:38:53 +0200 Subject: [PATCH 1/3] feat(platform): wire default model governance policy end-to-end - Add Zod validation for default_models policy type in upsertPolicy - Create getMyDefaultModel reactive query for client consumption - Add resolveDefaultModelInternal internal query for server-side use - Apply governance default in unified_chat when no explicit model sent - Insert governance default in model selector priority chain - Pass governance default through chat interface send path - Add DefaultModelEditor admin UI with rule CRUD, scope/team/role pickers - Add Default Models tab to governance settings page - Add i18n keys to all four locale files (en, de, de-CH, de-AT) - Add backend tests for resolveDefaultModel resolution logic - Add accessibility test for DefaultModelEditor component - Fix existing model-selector test to mock new useDefaultModel hook --- .../__tests__/model-selector.test.tsx | 4 + .../chat/components/chat-interface.tsx | 5 +- .../chat/components/model-selector.tsx | 26 +- .../features/chat/hooks/use-default-model.ts | 8 + .../components/default-model-editor.test.tsx | 69 ++ .../components/default-model-editor.tsx | 595 ++++++++++++++++++ .../dashboard/$id/settings/governance.tsx | 12 + services/platform/convex/_generated/api.d.ts | 2 + .../platform/convex/agents/unified_chat.ts | 19 +- .../__tests__/resolve_default_model.test.ts | 255 ++++++++ .../convex/governance/default_model_query.ts | 53 ++ .../convex/governance/internal_queries.ts | 47 ++ .../platform/convex/governance/mutations.ts | 10 + services/platform/messages/de-AT.json | 32 + services/platform/messages/de-CH.json | 30 + services/platform/messages/de.json | 26 +- services/platform/messages/en.json | 26 +- 17 files changed, 1211 insertions(+), 8 deletions(-) create mode 100644 services/platform/app/features/chat/hooks/use-default-model.ts create mode 100644 services/platform/app/features/settings/governance/components/default-model-editor.test.tsx create mode 100644 services/platform/app/features/settings/governance/components/default-model-editor.tsx create mode 100644 services/platform/convex/governance/__tests__/resolve_default_model.test.ts create mode 100644 services/platform/convex/governance/default_model_query.ts diff --git a/services/platform/app/features/chat/components/__tests__/model-selector.test.tsx b/services/platform/app/features/chat/components/__tests__/model-selector.test.tsx index 040eea82a1..eb2dd23e5f 100644 --- a/services/platform/app/features/chat/components/__tests__/model-selector.test.tsx +++ b/services/platform/app/features/chat/components/__tests__/model-selector.test.tsx @@ -67,6 +67,10 @@ vi.mock('@/app/features/settings/providers/hooks/queries', () => ({ }), })); +vi.mock('../../hooks/use-default-model', () => ({ + useDefaultModel: () => ({ data: null }), +})); + vi.mock('../model-tag-icons', () => ({ ModelTagIcons: () => null, })); diff --git a/services/platform/app/features/chat/components/chat-interface.tsx b/services/platform/app/features/chat/components/chat-interface.tsx index 1efcd9a3f2..d15c049b97 100644 --- a/services/platform/app/features/chat/components/chat-interface.tsx +++ b/services/platform/app/features/chat/components/chat-interface.tsx @@ -35,6 +35,7 @@ import { } from '../hooks/queries'; import { useChatLoadingState } from '../hooks/use-chat-loading-state'; import { useConvexFileUpload } from '../hooks/use-convex-file-upload'; +import { useDefaultModel } from '../hooks/use-default-model'; import { useEffectiveAgent } from '../hooks/use-effective-agent'; import { useFileIndexingStatus } from '../hooks/use-file-indexing-status'; import { useMergedChatItems } from '../hooks/use-merged-chat-items'; @@ -126,6 +127,7 @@ export function ChatInterface({ const { agent: effectiveAgent, isLoading: isAgentLoading } = useEffectiveAgent(organizationId); + const { data: governanceDefault } = useDefaultModel(organizationId); const [inputValue, setInputValue, clearInputValue] = usePersistedState( chatDraftKey(user?.userId, organizationId, threadId), @@ -422,7 +424,8 @@ export function ChatInterface({ }, selectedAgent: effectiveAgent, modelId: effectiveAgent?.name - ? selectedModelOverrides[effectiveAgent.name] + ? (selectedModelOverrides[effectiveAgent.name] ?? + governanceDefault?.modelId) : undefined, userContext, arena: arenaContext ?? undefined, diff --git a/services/platform/app/features/chat/components/model-selector.tsx b/services/platform/app/features/chat/components/model-selector.tsx index 9f4ef80551..3ec9d815b5 100644 --- a/services/platform/app/features/chat/components/model-selector.tsx +++ b/services/platform/app/features/chat/components/model-selector.tsx @@ -18,6 +18,7 @@ import { useT } from '@/lib/i18n/client'; import { useChatLayout } from '../context/chat-layout-context'; import { useChatAgents } from '../hooks/queries'; +import { useDefaultModel } from '../hooks/use-default-model'; import { useEffectiveAgent } from '../hooks/use-effective-agent'; import { ModelTagIcons } from './model-tag-icons'; @@ -36,6 +37,7 @@ export function ModelSelector({ organizationId }: ModelSelectorProps) { const { agents } = useChatAgents(organizationId); const { providers } = useListProviders('default'); const { selectedModelOverrides, setSelectedModelOverride } = useChatLayout(); + const { data: governanceDefault } = useDefaultModel(organizationId); const [open, setOpen] = useState(false); const supportedModels = useMemo(() => { @@ -88,10 +90,26 @@ export function ModelSelector({ organizationId }: ModelSelectorProps) { [modelInfoMap], ); - const currentModelId = - (effectiveAgent?.name && selectedModelOverrides[effectiveAgent.name]) || - filteredModels[0] || - null; + const currentModelId = useMemo(() => { + // 1. User's explicit override (localStorage) takes highest priority + if (effectiveAgent?.name && selectedModelOverrides[effectiveAgent.name]) { + return selectedModelOverrides[effectiveAgent.name]; + } + // 2. Governance team/role default (if model is in agent's supported list) + if ( + governanceDefault?.modelId && + filteredModels.includes(governanceDefault.modelId) + ) { + return governanceDefault.modelId; + } + // 3. Agent's primary model + return filteredModels[0] ?? null; + }, [ + effectiveAgent?.name, + selectedModelOverrides, + governanceDefault, + filteredModels, + ]); // Clear stale override when agent changes useEffect(() => { diff --git a/services/platform/app/features/chat/hooks/use-default-model.ts b/services/platform/app/features/chat/hooks/use-default-model.ts new file mode 100644 index 0000000000..ac7aade83c --- /dev/null +++ b/services/platform/app/features/chat/hooks/use-default-model.ts @@ -0,0 +1,8 @@ +import { useConvexQuery } from '@/app/hooks/use-convex-query'; +import { api } from '@/convex/_generated/api'; + +export function useDefaultModel(organizationId: string) { + return useConvexQuery(api.governance.default_model_query.getMyDefaultModel, { + organizationId, + }); +} diff --git a/services/platform/app/features/settings/governance/components/default-model-editor.test.tsx b/services/platform/app/features/settings/governance/components/default-model-editor.test.tsx new file mode 100644 index 0000000000..7aa65c620f --- /dev/null +++ b/services/platform/app/features/settings/governance/components/default-model-editor.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, vi } from 'vitest'; + +import { checkAccessibility } from '@/test/utils/a11y'; +import { render } from '@/test/utils/render'; + +import { DefaultModelEditor } from './default-model-editor'; + +vi.mock('@/app/hooks/use-toast', () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock('../hooks/mutations', () => ({ + useUpsertGovernancePolicy: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock('../hooks/queries', () => ({ + useGovernancePolicy: () => ({ + data: null, + isLoading: false, + }), +})); + +vi.mock('@/app/features/settings/teams/hooks/queries', () => ({ + useOrgTeams: () => ({ + teams: [{ id: 'team-1', name: 'Engineering' }], + isLoading: false, + }), +})); + +vi.mock('@/app/features/settings/providers/hooks/queries', () => ({ + useListProviders: () => ({ + providers: [ + { + name: 'openai', + displayName: 'OpenAI', + models: [ + { + id: 'openai/gpt-4o', + displayName: 'GPT-4o', + tags: ['chat'], + }, + ], + }, + ], + isLoading: false, + }), +})); + +vi.mock('@/app/hooks/use-ability', () => ({ + useAbility: () => ({ + can: () => true, + cannot: () => false, + }), +})); + +vi.mock('@/app/hooks/use-organization-id', () => ({ + useOrganizationId: () => 'org-1', +})); + +describe('DefaultModelEditor', () => { + describe('accessibility', () => { + it('passes axe audit', async () => { + const { container } = render( + , + ); + await checkAccessibility(container); + }); + }); +}); diff --git a/services/platform/app/features/settings/governance/components/default-model-editor.tsx b/services/platform/app/features/settings/governance/components/default-model-editor.tsx new file mode 100644 index 0000000000..298b029b9a --- /dev/null +++ b/services/platform/app/features/settings/governance/components/default-model-editor.tsx @@ -0,0 +1,595 @@ +'use client'; + +import { Pencil, Plus, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; +import { SearchableSelect } from '@/app/components/ui/forms/searchable-select'; +import { Select } from '@/app/components/ui/forms/select'; +import { Switch } from '@/app/components/ui/forms/switch'; +import { HStack, Stack } from '@/app/components/ui/layout/layout'; +import { PageSection } from '@/app/components/ui/layout/page-section'; +import { Button } from '@/app/components/ui/primitives/button'; +import { Text } from '@/app/components/ui/typography/text'; +import { useListProviders } from '@/app/features/settings/providers/hooks/queries'; +import { useOrgTeams } from '@/app/features/settings/teams/hooks/queries'; +import { useAbility } from '@/app/hooks/use-ability'; +import { useToast } from '@/app/hooks/use-toast'; +import { useT } from '@/lib/i18n/client'; +import { + defaultModelsConfigSchema, + type DefaultModelsConfig, + type DefaultModelRule, +} from '@/lib/shared/schemas/governance'; +import { isRecord } from '@/lib/utils/type-guards'; + +import { useUpsertGovernancePolicy } from '../hooks/mutations'; +import { useGovernancePolicy } from '../hooks/queries'; + +interface DefaultModelEditorProps { + organizationId: string; +} + +const SCOPE_OPTIONS = [ + { value: 'default', label: 'Default' }, + { value: 'team', label: 'Team' }, + { value: 'role', label: 'Role' }, +]; + +function isScopeValue(v: string): v is DefaultModelRule['scope'] { + return SCOPE_OPTIONS.some((o) => o.value === v); +} + +const ROLE_OPTIONS = [ + { value: 'admin', label: 'Admin' }, + { value: 'developer', label: 'Developer' }, + { value: 'editor', label: 'Editor' }, + { value: 'member', label: 'Member' }, +]; + +function emptyRule(): DefaultModelRule { + return { + scope: 'default', + providerName: '', + modelId: '', + }; +} + +function parseDefaultModelsConfig(policy: unknown): DefaultModelsConfig { + const config = isRecord(policy) ? policy : {}; + const result = defaultModelsConfigSchema.safeParse(config); + if (result.success) { + return result.data; + } + return { enabled: false, rules: [] }; +} + +interface ProviderModel { + id: string; + displayName: string; + tags: string[]; +} + +interface ProviderInfo { + name: string; + displayName: string; + models: ProviderModel[]; +} + +interface RuleDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rule: DefaultModelRule; + onSave: (rule: DefaultModelRule) => void; + title: string; + cannotManage: boolean; + teamOptions: { value: string; label: string }[]; + providerList: ProviderInfo[]; +} + +function RuleDialog({ + open, + onOpenChange, + rule: initialRule, + onSave, + title, + cannotManage, + teamOptions, + providerList, +}: RuleDialogProps) { + const { t } = useT('governance'); + const [draft, setDraft] = useState(initialRule); + + useEffect(() => { + if (open) { + setDraft(initialRule); + } + }, [open, initialRule]); + + const updateDraft = useCallback((patch: Partial) => { + setDraft((prev) => { + const updated = { ...prev, ...patch }; + if (patch.scope === 'default') { + delete updated.scopeId; + } + if (patch.providerName && patch.providerName !== prev.providerName) { + updated.modelId = ''; + } + return updated; + }); + }, []); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + onSave(draft); + onOpenChange(false); + }, + [draft, onSave, onOpenChange], + ); + + const providerOptions = useMemo( + () => + providerList.map((p) => ({ + value: p.name, + label: p.displayName || p.name, + })), + [providerList], + ); + + const modelOptions = useMemo(() => { + const provider = providerList.find((p) => p.name === draft.providerName); + if (!provider) return []; + return provider.models + .filter((m) => m.tags.includes('chat')) + .map((m) => ({ + value: m.id, + label: m.displayName || m.id, + })); + }, [providerList, draft.providerName]); + + return ( + + + +
+ updateDraft({ scopeId: value })} + disabled={cannotManage} + size="sm" + /> +
+ )} + + {draft.scope === 'team' && ( +
+ + {t('defaultModels.target')} + + updateDraft({ scopeId: value })} + options={teamOptions} + searchPlaceholder={t('defaultModels.searchTeams')} + emptyText={t('defaultModels.noTeamsFound')} + aria-label={t('defaultModels.target')} + trigger={ + + } + /> +
+ )} +
+ + +
+ + {t('defaultModels.provider')} + + updateDraft({ providerName: value })} + options={providerOptions} + searchPlaceholder={t('defaultModels.searchProviders')} + emptyText={t('defaultModels.noProvidersFound')} + aria-label={t('defaultModels.provider')} + trigger={ + + } + /> +
+ +
+ + {t('defaultModels.model')} + + updateDraft({ modelId: value })} + options={modelOptions} + searchPlaceholder={t('defaultModels.searchModels')} + emptyText={t('defaultModels.noModelsFound')} + aria-label={t('defaultModels.model')} + trigger={ + + } + /> +
+
+
+
+ ); +} + +export function DefaultModelEditor({ + organizationId, +}: DefaultModelEditorProps) { + const { t } = useT('governance'); + const { toast } = useToast(); + const ability = useAbility(); + + const { data: policy, isLoading } = useGovernancePolicy( + organizationId, + 'default_models', + ); + const upsertMutation = useUpsertGovernancePolicy(); + const { teams } = useOrgTeams(); + const { providers } = useListProviders('default'); + + const teamOptions = useMemo( + () => + (teams ?? []).map((team) => ({ + value: team.id, + label: team.name || team.id, + })), + [teams], + ); + + const providerList = useMemo(() => { + const list: ProviderInfo[] = []; + for (const provider of providers) { + if ( + !provider || + !('models' in provider) || + !Array.isArray(provider.models) + ) + continue; + list.push({ + name: provider.name, + displayName: provider.displayName ?? provider.name, + models: provider.models.map( + (m: { id: string; displayName: string; tags?: string[] }) => ({ + id: m.id, + displayName: m.displayName, + tags: m.tags ?? [], + }), + ), + }); + } + return list; + }, [providers]); + + const savedConfig = useMemo( + () => parseDefaultModelsConfig(policy?.config), + [policy], + ); + + const [enabled, setEnabled] = useState(false); + const [rules, setRules] = useState([]); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [dialogRule, setDialogRule] = useState(emptyRule()); + + useEffect(() => { + setEnabled(savedConfig.enabled); + setRules(savedConfig.rules); + }, [savedConfig]); + + const cannotManage = ability.cannot('write', 'orgSettings'); + + const saveConfig = useCallback( + async (configToSave: { enabled: boolean; rules: DefaultModelRule[] }) => { + try { + await upsertMutation.mutateAsync({ + organizationId, + policyType: 'default_models', + config: configToSave, + }); + toast({ title: t('defaultModels.saved'), variant: 'success' }); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : 'Failed to save'; + toast({ title: message, variant: 'destructive' }); + } + }, + [organizationId, upsertMutation, toast, t], + ); + + const handleToggleEnabled = useCallback( + (checked: boolean) => { + setEnabled(checked); + void saveConfig({ enabled: checked, rules }); + }, + [saveConfig, rules], + ); + + const removeRule = useCallback( + (index: number) => { + const newRules = rules.filter((_, i) => i !== index); + setRules(newRules); + void saveConfig({ enabled, rules: newRules }); + }, + [rules, enabled, saveConfig], + ); + + const openAddDialog = useCallback(() => { + setEditingIndex(null); + setDialogRule(emptyRule()); + setDialogOpen(true); + }, []); + + const openEditDialog = useCallback( + (index: number) => { + setEditingIndex(index); + setDialogRule(rules[index]); + setDialogOpen(true); + }, + [rules], + ); + + const handleDialogSave = useCallback( + (rule: DefaultModelRule) => { + let newRules: DefaultModelRule[]; + if (editingIndex === null) { + newRules = [...rules, rule]; + } else { + newRules = rules.map((r, i) => (i === editingIndex ? rule : r)); + } + setRules(newRules); + void saveConfig({ enabled, rules: newRules }); + }, + [editingIndex, rules, enabled, saveConfig], + ); + + const resolveTarget = useCallback( + (rule: DefaultModelRule): string => { + switch (rule.scope) { + case 'team': { + if (!rule.scopeId) return '\u2014'; + return ( + teamOptions.find((o) => o.value === rule.scopeId)?.label ?? + rule.scopeId + ); + } + case 'role': + return rule.scopeId ?? '\u2014'; + case 'default': + return t('defaultModels.allUsers'); + default: + return '\u2014'; + } + }, + [teamOptions, t], + ); + + const resolveModelName = useCallback( + (rule: DefaultModelRule): string => { + for (const provider of providerList) { + if (provider.name !== rule.providerName) continue; + const model = provider.models.find((m) => m.id === rule.modelId); + if (model) return model.displayName; + } + return rule.modelId; + }, + [providerList], + ); + + const resolveProviderName = useCallback( + (rule: DefaultModelRule): string => { + const provider = providerList.find((p) => p.name === rule.providerName); + return provider?.displayName ?? rule.providerName; + }, + [providerList], + ); + + if (isLoading) { + return null; + } + + return ( + + } + > + + + {rules.length > 0 ? ( +
+ + + + + + + + + + + + + {rules.map((rule, index) => ( + + + + + + + + ))} + +
+ {t('defaultModels.title')} +
+ {t('defaultModels.scope')} + + {t('defaultModels.target')} + + {t('defaultModels.provider')} + + {t('defaultModels.model')} + + {t('defaultModels.actions')} +
{rule.scope}{resolveTarget(rule)}{resolveProviderName(rule)}{resolveModelName(rule)} + + + + +
+
+ ) : ( + + {t('defaultModels.noRules')} + + )} + + +
+
+ + +
+ ); +} diff --git a/services/platform/app/routes/dashboard/$id/settings/governance.tsx b/services/platform/app/routes/dashboard/$id/settings/governance.tsx index 7cb91859d4..e74ce30a62 100644 --- a/services/platform/app/routes/dashboard/$id/settings/governance.tsx +++ b/services/platform/app/routes/dashboard/$id/settings/governance.tsx @@ -12,8 +12,15 @@ import { SystemPromptEditor } from '@/app/features/settings/governance/component import { UsageDashboard } from '@/app/features/settings/governance/components/usage-dashboard'; import { useAbility, useAbilityLoading } from '@/app/hooks/use-ability'; import { useT } from '@/lib/i18n/client'; +import { lazyComponent } from '@/lib/utils/lazy-component'; import { seo } from '@/lib/utils/seo'; +const DefaultModelEditor = lazyComponent<{ organizationId: string }>(() => + import('@/app/features/settings/governance/components/default-model-editor').then( + (m) => ({ default: m.DefaultModelEditor }), + ), +); + const searchSchema = z.object({ tab: z.string().optional(), }); @@ -58,6 +65,11 @@ function GovernanceSettingsPage() { label: t('tabs.budgets'), content: , }, + { + value: 'default-models', + label: t('defaultModels.title'), + content: , + }, { value: 'retention', label: 'Retention', diff --git a/services/platform/convex/_generated/api.d.ts b/services/platform/convex/_generated/api.d.ts index f241ec6b2f..2b4a7b6a89 100644 --- a/services/platform/convex/_generated/api.d.ts +++ b/services/platform/convex/_generated/api.d.ts @@ -286,6 +286,7 @@ import type * as folders_mutations from "../folders/mutations.js"; import type * as folders_queries from "../folders/queries.js"; import type * as governance_budget_enforcement from "../governance/budget_enforcement.js"; import type * as governance_cost_estimation from "../governance/cost_estimation.js"; +import type * as governance_default_model_query from "../governance/default_model_query.js"; import type * as governance_entra_attribute_matcher from "../governance/entra_attribute_matcher.js"; import type * as governance_feature_enforcement from "../governance/feature_enforcement.js"; import type * as governance_helpers from "../governance/helpers.js"; @@ -1209,6 +1210,7 @@ declare const fullApi: ApiFromModules<{ "folders/queries": typeof folders_queries; "governance/budget_enforcement": typeof governance_budget_enforcement; "governance/cost_estimation": typeof governance_cost_estimation; + "governance/default_model_query": typeof governance_default_model_query; "governance/entra_attribute_matcher": typeof governance_entra_attribute_matcher; "governance/feature_enforcement": typeof governance_feature_enforcement; "governance/helpers": typeof governance_helpers; diff --git a/services/platform/convex/agents/unified_chat.ts b/services/platform/convex/agents/unified_chat.ts index ced431c8ee..c244be7060 100644 --- a/services/platform/convex/agents/unified_chat.ts +++ b/services/platform/convex/agents/unified_chat.ts @@ -93,13 +93,30 @@ export const chatWithAgent = action({ } } + // Resolve governance default model when no explicit model is provided + let effectiveModelId = args.modelId; + if (!effectiveModelId) { + const governanceDefault = await ctx.runQuery( + internal.governance.internal_queries.resolveDefaultModelInternal, + { + organizationId: args.organizationId, + userId: String(authUser._id), + userEmail: authUser.email, + userName: authUser.name, + }, + ); + if (governanceDefault) { + effectiveModelId = governanceDefault.modelId; + } + } + const agentConfig = await ctx.runAction( internal.agents.file_actions.resolveAgentConfig, { orgSlug: args.orgSlug, agentSlug: args.agentSlug, organizationId: args.organizationId, - modelId: args.modelId, + modelId: effectiveModelId, }, ); diff --git a/services/platform/convex/governance/__tests__/resolve_default_model.test.ts b/services/platform/convex/governance/__tests__/resolve_default_model.test.ts new file mode 100644 index 0000000000..05fc491f92 --- /dev/null +++ b/services/platform/convex/governance/__tests__/resolve_default_model.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockReadPolicyConfig = vi.fn(); + +vi.mock('../helpers', () => ({ + readPolicyConfig: (...args: unknown[]) => mockReadPolicyConfig(...args), +})); + +// Import after mocks +const { resolveDefaultModel } = await import('../resolve_default_model'); + +// Minimal ctx stub (only used to pass through to readPolicyConfig) +const ctx = {} as Parameters[0]; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('resolveDefaultModel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns null when no default_models policy exists', async () => { + mockReadPolicyConfig.mockResolvedValue(null); + + const result = await resolveDefaultModel( + ctx, + 'org-1', + ['team-1'], + 'member', + ); + + expect(result).toBeNull(); + expect(mockReadPolicyConfig).toHaveBeenCalledWith( + ctx, + 'org-1', + 'default_models', + ); + }); + + it('returns null when policy is disabled', async () => { + mockReadPolicyConfig.mockResolvedValue({ + enabled: false, + rules: [ + { + scope: 'default', + providerName: 'openai', + modelId: 'gpt-4o', + }, + ], + }); + + const result = await resolveDefaultModel( + ctx, + 'org-1', + ['team-1'], + 'member', + ); + + expect(result).toBeNull(); + }); + + it('returns null when rules array is empty', async () => { + mockReadPolicyConfig.mockResolvedValue({ + enabled: true, + rules: [], + }); + + const result = await resolveDefaultModel( + ctx, + 'org-1', + ['team-1'], + 'member', + ); + + expect(result).toBeNull(); + }); + + it('returns team-scoped rule when user belongs to that team', async () => { + mockReadPolicyConfig.mockResolvedValue({ + enabled: true, + rules: [ + { + scope: 'team', + scopeId: 'team-engineering', + providerName: 'openai', + modelId: 'gpt-4o', + }, + { + scope: 'default', + providerName: 'anthropic', + modelId: 'claude-sonnet', + }, + ], + }); + + const result = await resolveDefaultModel( + ctx, + 'org-1', + ['team-engineering'], + 'member', + ); + + expect(result).toEqual({ providerName: 'openai', modelId: 'gpt-4o' }); + }); + + it('team rule takes priority over role rule', async () => { + mockReadPolicyConfig.mockResolvedValue({ + enabled: true, + rules: [ + { + scope: 'role', + scopeId: 'admin', + providerName: 'anthropic', + modelId: 'claude-opus', + }, + { + scope: 'team', + scopeId: 'team-1', + providerName: 'openai', + modelId: 'gpt-4o', + }, + { + scope: 'default', + providerName: 'google', + modelId: 'gemini-pro', + }, + ], + }); + + const result = await resolveDefaultModel(ctx, 'org-1', ['team-1'], 'admin'); + + expect(result).toEqual({ providerName: 'openai', modelId: 'gpt-4o' }); + }); + + it('role rule takes priority over default rule', async () => { + mockReadPolicyConfig.mockResolvedValue({ + enabled: true, + rules: [ + { + scope: 'role', + scopeId: 'developer', + providerName: 'anthropic', + modelId: 'claude-sonnet', + }, + { + scope: 'default', + providerName: 'openai', + modelId: 'gpt-4o-mini', + }, + ], + }); + + const result = await resolveDefaultModel( + ctx, + 'org-1', + ['team-unrelated'], + 'developer', + ); + + expect(result).toEqual({ + providerName: 'anthropic', + modelId: 'claude-sonnet', + }); + }); + + it('returns default rule when no team or role matches', async () => { + mockReadPolicyConfig.mockResolvedValue({ + enabled: true, + rules: [ + { + scope: 'team', + scopeId: 'team-engineering', + providerName: 'openai', + modelId: 'gpt-4o', + }, + { + scope: 'role', + scopeId: 'admin', + providerName: 'anthropic', + modelId: 'claude-opus', + }, + { + scope: 'default', + providerName: 'google', + modelId: 'gemini-pro', + }, + ], + }); + + const result = await resolveDefaultModel( + ctx, + 'org-1', + ['team-marketing'], + 'member', + ); + + expect(result).toEqual({ providerName: 'google', modelId: 'gemini-pro' }); + }); + + it('multi-team membership: first matching rule wins by rules array order', async () => { + mockReadPolicyConfig.mockResolvedValue({ + enabled: true, + rules: [ + { + scope: 'team', + scopeId: 'team-b', + providerName: 'anthropic', + modelId: 'claude-sonnet', + }, + { + scope: 'team', + scopeId: 'team-a', + providerName: 'openai', + modelId: 'gpt-4o', + }, + ], + }); + + const result = await resolveDefaultModel( + ctx, + 'org-1', + ['team-a', 'team-b'], + 'member', + ); + + expect(result).toEqual({ + providerName: 'anthropic', + modelId: 'claude-sonnet', + }); + }); + + it('returns null when no userRole is provided and only role rules exist', async () => { + mockReadPolicyConfig.mockResolvedValue({ + enabled: true, + rules: [ + { + scope: 'role', + scopeId: 'admin', + providerName: 'openai', + modelId: 'gpt-4o', + }, + ], + }); + + const result = await resolveDefaultModel(ctx, 'org-1', [], undefined); + + expect(result).toBeNull(); + }); +}); diff --git a/services/platform/convex/governance/default_model_query.ts b/services/platform/convex/governance/default_model_query.ts new file mode 100644 index 0000000000..bdd2f18496 --- /dev/null +++ b/services/platform/convex/governance/default_model_query.ts @@ -0,0 +1,53 @@ +import { v } from 'convex/values'; + +import { components } from '../_generated/api'; +import { query } from '../_generated/server'; +import { authComponent } from '../auth'; +import { getOrganizationMember } from '../lib/rls'; +import { resolveDefaultModel } from './resolve_default_model'; + +interface BetterAuthTeamMember { + teamId: string; +} + +interface BetterAuthFindManyResult { + page: T[]; + continueCursor: string; + isDone: boolean; +} + +export const getMyDefaultModel = query({ + args: { + organizationId: v.string(), + }, + returns: v.union( + v.object({ + providerName: v.string(), + modelId: v.string(), + }), + v.null(), + ), + handler: async (ctx, args) => { + const authUser = await authComponent.getAuthUser(ctx); + if (!authUser) return null; + + const member = await getOrganizationMember(ctx, args.organizationId, { + userId: String(authUser._id), + email: authUser.email, + name: authUser.name, + }); + + const membershipsResult: BetterAuthFindManyResult = + await ctx.runQuery(components.betterAuth.adapter.findMany, { + model: 'teamMember', + paginationOpts: { cursor: null, numItems: 100 }, + where: [ + { field: 'userId', operator: 'eq', value: String(authUser._id) }, + ], + }); + + const teamIds = membershipsResult?.page.map((m) => m.teamId) ?? []; + + return resolveDefaultModel(ctx, args.organizationId, teamIds, member.role); + }, +}); diff --git a/services/platform/convex/governance/internal_queries.ts b/services/platform/convex/governance/internal_queries.ts index 4e8f08424e..15a39c54ea 100644 --- a/services/platform/convex/governance/internal_queries.ts +++ b/services/platform/convex/governance/internal_queries.ts @@ -1,6 +1,9 @@ import { v } from 'convex/values'; +import { components } from '../_generated/api'; import { internalQuery } from '../_generated/server'; +import { getOrganizationMember } from '../lib/rls'; +import { resolveDefaultModel } from './resolve_default_model'; export const getPiiConfigInternal = internalQuery({ args: { @@ -103,3 +106,47 @@ export const listExpiredDocuments = internalQuery({ return docs; }, }); + +interface BetterAuthTeamMember { + teamId: string; +} + +interface BetterAuthFindManyResult { + page: T[]; + continueCursor: string; + isDone: boolean; +} + +export const resolveDefaultModelInternal = internalQuery({ + args: { + organizationId: v.string(), + userId: v.string(), + userEmail: v.string(), + userName: v.optional(v.string()), + }, + returns: v.union( + v.object({ + providerName: v.string(), + modelId: v.string(), + }), + v.null(), + ), + handler: async (ctx, args) => { + const member = await getOrganizationMember(ctx, args.organizationId, { + userId: args.userId, + email: args.userEmail, + name: args.userName, + }); + + const membershipsResult: BetterAuthFindManyResult = + await ctx.runQuery(components.betterAuth.adapter.findMany, { + model: 'teamMember', + paginationOpts: { cursor: null, numItems: 100 }, + where: [{ field: 'userId', operator: 'eq', value: args.userId }], + }); + + const teamIds = membershipsResult?.page.map((m) => m.teamId) ?? []; + + return resolveDefaultModel(ctx, args.organizationId, teamIds, member.role); + }, +}); diff --git a/services/platform/convex/governance/mutations.ts b/services/platform/convex/governance/mutations.ts index 4b23577eba..d56e221915 100644 --- a/services/platform/convex/governance/mutations.ts +++ b/services/platform/convex/governance/mutations.ts @@ -2,6 +2,7 @@ import { v } from 'convex/values'; import { budgetConfigSchema, + defaultModelsConfigSchema, featureFlagsConfigSchema, piiConfigSchema, } from '../../lib/shared/schemas/governance'; @@ -45,6 +46,15 @@ export const upsertPolicy = mutation({ } } + if (args.policyType === 'default_models') { + const parsed = defaultModelsConfigSchema.safeParse(args.config); + if (!parsed.success) { + throw new Error( + `Invalid default models configuration: ${parsed.error.message}`, + ); + } + } + if (args.policyType === 'pii_config') { const parsed = piiConfigSchema.safeParse(args.config); if (!parsed.success) { diff --git a/services/platform/messages/de-AT.json b/services/platform/messages/de-AT.json index 92255a5fd4..57b03f9ee2 100644 --- a/services/platform/messages/de-AT.json +++ b/services/platform/messages/de-AT.json @@ -57,6 +57,38 @@ }, "usageCount": "{count, plural, one {# Verwendung} other {# Verwendungen}}" }, + "governance": { + "defaultModels": { + "title": "Standardmodelle", + "description": "Standardmodelle für bestimmte Benutzergruppen oder Rollen festlegen.", + "addMapping": "Modellzuordnung hinzufügen", + "saved": "Standardmodell-Konfiguration gespeichert", + "addRule": "Regel hinzufügen", + "addRuleTitle": "Standardmodell-Regel hinzufügen", + "editRuleTitle": "Standardmodell-Regel bearbeiten", + "editRule": "Regel {index} bearbeiten", + "removeRule": "Regel {index} entfernen", + "scope": "Geltungsbereich", + "target": "Ziel", + "provider": "Anbieter", + "model": "Modell", + "role": "Rolle", + "actions": "Aktionen", + "noRules": "Keine Standardmodell-Regeln konfiguriert. Füge eine Regel hinzu, um Team- oder Rollen-Standards festzulegen.", + "allUsers": "Alle Benutzer", + "selectProvider": "Anbieter auswählen...", + "selectModel": "Modell auswählen...", + "selectTeam": "Team auswählen...", + "searchProviders": "Anbieter suchen...", + "searchModels": "Modelle suchen...", + "searchTeams": "Teams suchen...", + "noProvidersFound": "Keine Anbieter gefunden", + "noModelsFound": "Keine Modelle gefunden", + "noTeamsFound": "Keine Teams gefunden", + "enabled": "Aktiviert", + "confirm": "Bestätigen" + } + }, "settings": { "agents": { "form": { diff --git a/services/platform/messages/de-CH.json b/services/platform/messages/de-CH.json index a7bd686c58..2d78257a96 100644 --- a/services/platform/messages/de-CH.json +++ b/services/platform/messages/de-CH.json @@ -108,6 +108,36 @@ "uploadPolicy": { "description": "Erlaubte Dateitypen, maximale Dateigrösse und Volumenlimits konfigurieren.", "maxFileSize": "Maximale Dateigrösse" + }, + "defaultModels": { + "title": "Standardmodelle", + "description": "Standardmodelle für bestimmte Benutzergruppen oder Rollen festlegen.", + "addMapping": "Modellzuordnung hinzufügen", + "saved": "Standardmodell-Konfiguration gespeichert", + "addRule": "Regel hinzufügen", + "addRuleTitle": "Standardmodell-Regel hinzufügen", + "editRuleTitle": "Standardmodell-Regel bearbeiten", + "editRule": "Regel {index} bearbeiten", + "removeRule": "Regel {index} entfernen", + "scope": "Geltungsbereich", + "target": "Ziel", + "provider": "Anbieter", + "model": "Modell", + "role": "Rolle", + "actions": "Aktionen", + "noRules": "Keine Standardmodell-Regeln konfiguriert. Füge eine Regel hinzu, um Team- oder Rollen-Standards festzulegen.", + "allUsers": "Alle Benutzer", + "selectProvider": "Anbieter auswählen...", + "selectModel": "Modell auswählen...", + "selectTeam": "Team auswählen...", + "searchProviders": "Anbieter suchen...", + "searchModels": "Modelle suchen...", + "searchTeams": "Teams suchen...", + "noProvidersFound": "Keine Anbieter gefunden", + "noModelsFound": "Keine Modelle gefunden", + "noTeamsFound": "Keine Teams gefunden", + "enabled": "Aktiviert", + "confirm": "Bestätigen" } }, "prompts": { diff --git a/services/platform/messages/de.json b/services/platform/messages/de.json index a303f84308..a24af96dda 100644 --- a/services/platform/messages/de.json +++ b/services/platform/messages/de.json @@ -3696,7 +3696,31 @@ "title": "Standardmodelle", "description": "Standardmodelle für bestimmte Benutzergruppen oder Rollen festlegen.", "addMapping": "Modellzuordnung hinzufügen", - "saved": "Standardmodell-Konfiguration gespeichert" + "saved": "Standardmodell-Konfiguration gespeichert", + "addRule": "Regel hinzufügen", + "addRuleTitle": "Standardmodell-Regel hinzufügen", + "editRuleTitle": "Standardmodell-Regel bearbeiten", + "editRule": "Regel {index} bearbeiten", + "removeRule": "Regel {index} entfernen", + "scope": "Geltungsbereich", + "target": "Ziel", + "provider": "Anbieter", + "model": "Modell", + "role": "Rolle", + "actions": "Aktionen", + "noRules": "Keine Standardmodell-Regeln konfiguriert. Füge eine Regel hinzu, um Team- oder Rollen-Standards festzulegen.", + "allUsers": "Alle Benutzer", + "selectProvider": "Anbieter auswählen...", + "selectModel": "Modell auswählen...", + "selectTeam": "Team auswählen...", + "searchProviders": "Anbieter suchen...", + "searchModels": "Modelle suchen...", + "searchTeams": "Teams suchen...", + "noProvidersFound": "Keine Anbieter gefunden", + "noModelsFound": "Keine Modelle gefunden", + "noTeamsFound": "Keine Teams gefunden", + "enabled": "Aktiviert", + "confirm": "Bestätigen" }, "uploadPolicy": { "title": "Upload-Richtlinie", diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json index 5ef9169d09..de44438fd9 100644 --- a/services/platform/messages/en.json +++ b/services/platform/messages/en.json @@ -3686,7 +3686,31 @@ "title": "Default Models", "description": "Set default models for specific user groups or roles.", "addMapping": "Add model mapping", - "saved": "Default model configuration saved" + "saved": "Default model configuration saved", + "addRule": "Add rule", + "addRuleTitle": "Add default model rule", + "editRuleTitle": "Edit default model rule", + "editRule": "Edit rule {index}", + "removeRule": "Remove rule {index}", + "scope": "Scope", + "target": "Target", + "provider": "Provider", + "model": "Model", + "role": "Role", + "actions": "Actions", + "noRules": "No default model rules configured. Add a rule to set team or role defaults.", + "allUsers": "All users", + "selectProvider": "Select provider...", + "selectModel": "Select model...", + "selectTeam": "Select team...", + "searchProviders": "Search providers...", + "searchModels": "Search models...", + "searchTeams": "Search teams...", + "noProvidersFound": "No providers found", + "noModelsFound": "No models found", + "noTeamsFound": "No teams found", + "enabled": "Enabled", + "confirm": "Confirm" }, "uploadPolicy": { "title": "Upload Policy", From 36cf65b8179cae8c377601603a10b0bf7fcc805f Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Sat, 11 Apr 2026 04:13:22 +0200 Subject: [PATCH 2/3] fix(platform): address default model governance review feedback --- .../app/features/chat/components/chat-interface.tsx | 4 +++- .../governance/components/default-model-editor.tsx | 10 ++++++++-- .../platform/convex/governance/default_model_query.ts | 5 ++++- .../platform/convex/governance/internal_queries.ts | 4 ++-- services/platform/messages/en.json | 2 +- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/services/platform/app/features/chat/components/chat-interface.tsx b/services/platform/app/features/chat/components/chat-interface.tsx index d15c049b97..ef9d74876a 100644 --- a/services/platform/app/features/chat/components/chat-interface.tsx +++ b/services/platform/app/features/chat/components/chat-interface.tsx @@ -476,7 +476,8 @@ export function ChatInterface({ async (newContent: string) => { if (!editingMessage || !dataThreadId || !effectiveAgent) return; const modelId = effectiveAgent.name - ? selectedModelOverrides[effectiveAgent.name] + ? (selectedModelOverrides[effectiveAgent.name] ?? + governanceDefault?.modelId) : undefined; // Optimistic: show edited content immediately, truncate messages after it. @@ -513,6 +514,7 @@ export function ChatInterface({ rootThreadId, effectiveAgent, selectedModelOverrides, + governanceDefault, organizationId, userContext, editAndBranchAction, diff --git a/services/platform/app/features/settings/governance/components/default-model-editor.tsx b/services/platform/app/features/settings/governance/components/default-model-editor.tsx index 298b029b9a..be275c476e 100644 --- a/services/platform/app/features/settings/governance/components/default-model-editor.tsx +++ b/services/platform/app/features/settings/governance/components/default-model-editor.tsx @@ -109,8 +109,12 @@ function RuleDialog({ const updateDraft = useCallback((patch: Partial) => { setDraft((prev) => { const updated = { ...prev, ...patch }; - if (patch.scope === 'default') { - delete updated.scopeId; + if (patch.scope !== undefined) { + if (patch.scope === 'default') { + delete updated.scopeId; + } else { + updated.scopeId = ''; + } } if (patch.providerName && patch.providerName !== prev.providerName) { updated.modelId = ''; @@ -122,6 +126,8 @@ function RuleDialog({ const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); + if (!draft.providerName || !draft.modelId) return; + if (draft.scope !== 'default' && !draft.scopeId) return; onSave(draft); onOpenChange(false); }, diff --git a/services/platform/convex/governance/default_model_query.ts b/services/platform/convex/governance/default_model_query.ts index bdd2f18496..d2d41cfeed 100644 --- a/services/platform/convex/governance/default_model_query.ts +++ b/services/platform/convex/governance/default_model_query.ts @@ -28,7 +28,10 @@ export const getMyDefaultModel = query({ v.null(), ), handler: async (ctx, args) => { - const authUser = await authComponent.getAuthUser(ctx); + let authUser = null; + try { + authUser = await authComponent.getAuthUser(ctx); + } catch {} if (!authUser) return null; const member = await getOrganizationMember(ctx, args.organizationId, { diff --git a/services/platform/convex/governance/internal_queries.ts b/services/platform/convex/governance/internal_queries.ts index 15a39c54ea..126b608b0d 100644 --- a/services/platform/convex/governance/internal_queries.ts +++ b/services/platform/convex/governance/internal_queries.ts @@ -141,8 +141,8 @@ export const resolveDefaultModelInternal = internalQuery({ const membershipsResult: BetterAuthFindManyResult = await ctx.runQuery(components.betterAuth.adapter.findMany, { model: 'teamMember', - paginationOpts: { cursor: null, numItems: 100 }, - where: [{ field: 'userId', operator: 'eq', value: args.userId }], + paginationOpts: { cursor: null, numItems: 1000 }, + where: [{ field: 'userId', operator: 'eq', value: member.userId }], }); const teamIds = membershipsResult?.page.map((m) => m.teamId) ?? []; diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json index de44438fd9..c4ca4c28c8 100644 --- a/services/platform/messages/en.json +++ b/services/platform/messages/en.json @@ -1451,7 +1451,7 @@ }, "providers": { "title": "Providers", - "description": "Description", + "description": "Manage LLM providers and their models", "addProvider": "Add provider", "editProvider": "Edit provider", "deleteProvider": "Delete provider", From 0d2d524c44834ebe7bda46b1faa6b4d3d90999f7 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Sat, 11 Apr 2026 17:11:41 +0800 Subject: [PATCH 3/3] fix(platform): model selector override cleared incorrectly when governance default is active Selecting the agent's first model cleared the user override, causing the governance default to take over. Now the override is only cleared when the user picks the effective default (governance or agent primary). --- .../features/chat/components/model-selector.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/services/platform/app/features/chat/components/model-selector.tsx b/services/platform/app/features/chat/components/model-selector.tsx index 3ec9d815b5..e2c5bfba6c 100644 --- a/services/platform/app/features/chat/components/model-selector.tsx +++ b/services/platform/app/features/chat/components/model-selector.tsx @@ -128,13 +128,25 @@ export function ModelSelector({ organizationId }: ModelSelectorProps) { const handleSelect = useCallback( (modelId: string) => { if (!effectiveAgent?.name) return; - if (modelId === filteredModels[0]) { + // Only clear the override when the user picks the effective default + // (governance default if present, otherwise the agent's primary model). + const effectiveDefault = + governanceDefault?.modelId && + filteredModels.includes(governanceDefault.modelId) + ? governanceDefault.modelId + : filteredModels[0]; + if (modelId === effectiveDefault) { setSelectedModelOverride(effectiveAgent.name, null); } else { setSelectedModelOverride(effectiveAgent.name, modelId); } }, - [effectiveAgent?.name, filteredModels, setSelectedModelOverride], + [ + effectiveAgent?.name, + filteredModels, + governanceDefault, + setSelectedModelOverride, + ], ); if (!currentModelId) return null;