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..ef9d74876a 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, @@ -473,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. @@ -510,6 +514,7 @@ export function ChatInterface({ rootThreadId, effectiveAgent, selectedModelOverrides, + governanceDefault, organizationId, userContext, editAndBranchAction, diff --git a/services/platform/app/features/chat/components/model-selector.tsx b/services/platform/app/features/chat/components/model-selector.tsx index 9f4ef80551..e2c5bfba6c 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(() => { @@ -110,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; 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..be275c476e --- /dev/null +++ b/services/platform/app/features/settings/governance/components/default-model-editor.tsx @@ -0,0 +1,601 @@ +'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 !== undefined) { + if (patch.scope === 'default') { + delete updated.scopeId; + } else { + updated.scopeId = ''; + } + } + if (patch.providerName && patch.providerName !== prev.providerName) { + updated.modelId = ''; + } + return updated; + }); + }, []); + + 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); + }, + [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..d2d41cfeed --- /dev/null +++ b/services/platform/convex/governance/default_model_query.ts @@ -0,0 +1,56 @@ +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) => { + let authUser = null; + try { + authUser = await authComponent.getAuthUser(ctx); + } catch {} + 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..126b608b0d 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: 1000 }, + where: [{ field: 'userId', operator: 'eq', value: member.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..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", @@ -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",