diff --git a/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx b/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx index fe23aa97cdb..76dfdf26e49 100644 --- a/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx +++ b/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -21,6 +21,16 @@ import { TableRow, } from '@/components/ui/table'; import { InlineDeleteConfirmation } from '@/components/ui/inline-delete-confirmation'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { useCustomLlms, useCopyCustomLlm, @@ -36,7 +46,8 @@ import type { CustomLlmCredentials, CustomLlmDefinition } from '@kilocode/db/sch import { deepStrict } from '@/lib/zod/deep-strict'; import { formatZodError } from '@/lib/zod/format-zod-error'; import { toast } from 'sonner'; -import { Copy as CopyIcon, Plus, Pencil } from 'lucide-react'; +import { Copy as CopyIcon, Globe, Lock, Plus, Pencil } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; import Editor from '@monaco-editor/react'; const StrictCustomLlmDefinitionSchema = deepStrict(CustomLlmDefinitionSchema); @@ -46,6 +57,7 @@ type EditorState = { open: boolean; mode: 'create' | 'edit'; publicId: string; + wasPublic: boolean; credentialsJson: string; definitionJson: string; validationError: string | null; @@ -53,6 +65,7 @@ type EditorState = { type CopyState = { sourcePublicId: string; + sourceIsPublic: boolean; publicId: string; displayName: string; internalId: string; @@ -77,10 +90,33 @@ const INITIAL_CREDENTIALS: CustomLlmCredentials = { api_key: '', }; +type UpsertInput = { + public_id: string; + definition: CustomLlmDefinition; + credentials: CustomLlmCredentials | undefined; +}; + +type CopyInput = { + source_public_id: string; + public_id: string; + display_name: string; + internal_id: string | undefined; +}; + +/** A save that needs confirmation because it affects users beyond the editor. */ +type PendingChange = ( + | { kind: 'upsert'; input: UpsertInput } + | { kind: 'copy'; input: CopyInput } +) & { + makesPublic: boolean; + replacesKiloExclusiveModel: boolean; +}; + const initialEditorState: EditorState = { open: false, mode: 'create', publicId: '', + wasPublic: false, credentialsJson: JSON.stringify(INITIAL_CREDENTIALS, null, 2), definitionJson: JSON.stringify(INITIAL_DEFINITION, null, 2), validationError: null, @@ -93,12 +129,18 @@ export function CustomLlmsContent() { const deleteMutation = useDeleteCustomLlm(); const [editor, setEditor] = useState(initialEditorState); const [copy, setCopy] = useState(null); + const [pendingChange, setPendingChange] = useState(null); + const kiloExclusiveModelIds = useMemo( + () => new Set(data?.kiloExclusiveModelIds), + [data?.kiloExclusiveModelIds] + ); const openCreate = useCallback(() => { setEditor({ open: true, mode: 'create', publicId: '', + wasPublic: false, credentialsJson: JSON.stringify(INITIAL_CREDENTIALS, null, 2), definitionJson: JSON.stringify(INITIAL_DEFINITION, null, 2), validationError: null, @@ -110,6 +152,7 @@ export function CustomLlmsContent() { open: true, mode: 'edit', publicId, + wasPublic: definition.public !== undefined, credentialsJson: '', definitionJson: JSON.stringify(definition, null, 2), validationError: null, @@ -120,23 +163,54 @@ export function CustomLlmsContent() { setEditor(initialEditorState); }, []); - const openCopy = useCallback( - (sourcePublicId: string, sourceDisplayName: string, sourceInternalId: string | undefined) => { - setCopy({ - sourcePublicId, - publicId: sourcePublicId, - displayName: sourceDisplayName, - internalId: sourceInternalId ?? '', - validationError: null, - }); - }, - [] - ); + const openCopy = useCallback((sourcePublicId: string, source: CustomLlmDefinition) => { + setCopy({ + sourcePublicId, + sourceIsPublic: source.public !== undefined, + publicId: sourcePublicId, + displayName: source.display_name, + internalId: source.internal_id ?? '', + validationError: null, + }); + }, []); const closeCopy = useCallback(() => { setCopy(null); }, []); + const submitCopy = useCallback( + async (input: CopyInput) => { + try { + await copyMutation.mutateAsync(input); + toast.success('Custom LLM copied'); + closeCopy(); + } catch (error) { + setCopy(prev => + prev + ? { + ...prev, + validationError: { field: null, message: formatZodError(error) }, + } + : prev + ); + } + }, + [copyMutation, closeCopy] + ); + + const submitUpsert = useCallback( + async (input: UpsertInput) => { + try { + await upsertMutation.mutateAsync(input); + toast.success(editor.mode === 'create' ? 'Custom LLM created' : 'Custom LLM updated'); + closeEditor(); + } catch (error) { + toast.error(formatZodError(error)); + } + }, + [editor.mode, upsertMutation, closeEditor] + ); + const handleCopy = useCallback(async () => { if (!copy) return; @@ -181,26 +255,20 @@ export function CustomLlmsContent() { return; } - try { - await copyMutation.mutateAsync({ - source_public_id: copy.sourcePublicId, - public_id: publicId, - display_name: displayName, - internal_id: internalId || undefined, - }); - toast.success('Custom LLM copied'); - closeCopy(); - } catch (error) { - setCopy(prev => - prev - ? { - ...prev, - validationError: { field: null, message: formatZodError(error) }, - } - : prev - ); + const input: CopyInput = { + source_public_id: copy.sourcePublicId, + public_id: publicId, + display_name: displayName, + internal_id: internalId || undefined, + }; + const makesPublic = copy.sourceIsPublic; + const replacesKiloExclusiveModel = kiloExclusiveModelIds.has(publicId); + if (makesPublic || replacesKiloExclusiveModel) { + setPendingChange({ kind: 'copy', input, makesPublic, replacesKiloExclusiveModel }); + return; } - }, [copy, copyMutation, closeCopy]); + await submitCopy(input); + }, [copy, submitCopy, kiloExclusiveModelIds]); const handleSave = useCallback(async () => { const trimmedPublicId = editor.publicId.trim(); @@ -248,7 +316,11 @@ export function CustomLlmsContent() { return; } - const defResult = StrictCustomLlmDefinitionSchema.safeParse(parsedDefinition); + // The strict copy rejects unknown keys but drops cross-field rules, so check both. + const strictDefResult = StrictCustomLlmDefinitionSchema.safeParse(parsedDefinition); + const defResult = strictDefResult.success + ? CustomLlmDefinitionSchema.safeParse(parsedDefinition) + : strictDefResult; if (!defResult.success) { setEditor(prev => ({ ...prev, validationError: formatZodError(defResult.error) })); return; @@ -262,18 +334,27 @@ export function CustomLlmsContent() { return; } - try { - await upsertMutation.mutateAsync({ - public_id: trimmedPublicId, - definition: defResult.data, - credentials: parsedCredentials, - }); - toast.success(editor.mode === 'create' ? 'Custom LLM created' : 'Custom LLM updated'); - closeEditor(); - } catch (error) { - toast.error(formatZodError(error)); + const input: UpsertInput = { + public_id: trimmedPublicId, + definition: defResult.data, + credentials: parsedCredentials, + }; + const makesPublic = input.definition.public !== undefined && !editor.wasPublic; + const replacesKiloExclusiveModel = + editor.mode === 'create' && kiloExclusiveModelIds.has(trimmedPublicId); + if (makesPublic || replacesKiloExclusiveModel) { + setPendingChange({ kind: 'upsert', input, makesPublic, replacesKiloExclusiveModel }); + return; } - }, [editor, upsertMutation, closeEditor]); + await submitUpsert(input); + }, [editor, submitUpsert, kiloExclusiveModelIds]); + + const confirmPendingChange = useCallback(async () => { + const change = pendingChange; + setPendingChange(null); + if (change?.kind === 'upsert') await submitUpsert(change.input); + if (change?.kind === 'copy') await submitCopy(change.input); + }, [pendingChange, submitUpsert, submitCopy]); const handleDelete = useCallback( async (publicId: string) => { @@ -312,6 +393,7 @@ export function CustomLlmsContent() { Public ID Display Name + Access Internal ID Actions @@ -319,7 +401,7 @@ export function CustomLlmsContent() { {data?.items.length === 0 && ( - + No custom LLMs defined yet. @@ -328,6 +410,24 @@ export function CustomLlmsContent() { {item.public_id} {item.definition.display_name} + +
+ {item.definition.public ? ( + + + Public + + ) : ( + + + Private + + )} + {kiloExclusiveModelIds.has(item.public_id) && ( + Replaces Kilo-exclusive + )} +
+
{item.definition.internal_id ?? 'Not set'} @@ -345,13 +445,7 @@ export function CustomLlmsContent() {