diff --git a/apps/web/src/app/admin/api/custom-llms/hooks.ts b/apps/web/src/app/admin/api/custom-llms/hooks.ts index 6eecfcac99..5e7d3c027b 100644 --- a/apps/web/src/app/admin/api/custom-llms/hooks.ts +++ b/apps/web/src/app/admin/api/custom-llms/hooks.ts @@ -23,6 +23,21 @@ export function useUpsertCustomLlm() { ); } +export function useCopyCustomLlm() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + trpc.admin.customLlm.copy.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.admin.customLlm.list.queryKey(), + }); + }, + }) + ); +} + export function useDeleteCustomLlm() { const trpc = useTRPC(); const queryClient = useQueryClient(); diff --git a/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx b/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx index d43c3b6d95..ae3b008691 100644 --- a/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx +++ b/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx @@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, DialogFooter, @@ -22,6 +23,7 @@ import { import { InlineDeleteConfirmation } from '@/components/ui/inline-delete-confirmation'; import { useCustomLlms, + useCopyCustomLlm, useUpsertCustomLlm, useDeleteCustomLlm, } from '@/app/admin/api/custom-llms/hooks'; @@ -31,7 +33,7 @@ import { deepStrict } from '@/lib/zod/deep-strict'; import { formatZodError } from '@/lib/zod/format-zod-error'; import { CUSTOM_LLM_PREFIX } from '@/lib/ai-gateway/model-utils'; import { toast } from 'sonner'; -import { Plus, Pencil } from 'lucide-react'; +import { Copy as CopyIcon, Plus, Pencil } from 'lucide-react'; import Editor from '@monaco-editor/react'; const StrictCustomLlmDefinitionSchema = deepStrict(CustomLlmDefinitionSchema); @@ -46,6 +48,16 @@ type EditorState = { validationError: string | null; }; +type CopyState = { + sourcePublicId: string; + publicId: string; + displayName: string; + validationError: { + field: 'publicId' | 'displayName' | null; + message: string; + } | null; +}; + const INITIAL_DEFINITION: CustomLlmDefinition = { internal_id: '', display_name: '', @@ -73,8 +85,10 @@ const initialEditorState: EditorState = { export function CustomLlmsContent() { const { data, isLoading } = useCustomLlms(); const upsertMutation = useUpsertCustomLlm(); + const copyMutation = useCopyCustomLlm(); const deleteMutation = useDeleteCustomLlm(); const [editor, setEditor] = useState(initialEditorState); + const [copy, setCopy] = useState(null); const openCreate = useCallback(() => { setEditor({ @@ -102,6 +116,84 @@ export function CustomLlmsContent() { setEditor(initialEditorState); }, []); + const openCopy = useCallback((sourcePublicId: string, sourceDisplayName: string) => { + setCopy({ + sourcePublicId, + publicId: sourcePublicId, + displayName: sourceDisplayName, + validationError: null, + }); + }, []); + + const closeCopy = useCallback(() => { + setCopy(null); + }, []); + + const handleCopy = useCallback(async () => { + if (!copy) return; + + const publicId = copy.publicId.trim(); + const displayName = copy.displayName.trim(); + + if (!publicId) { + setCopy(prev => + prev + ? { + ...prev, + validationError: { field: 'publicId', message: 'New public ID is required' }, + } + : prev + ); + return; + } + + if (!publicId.startsWith(CUSTOM_LLM_PREFIX)) { + setCopy(prev => + prev + ? { + ...prev, + validationError: { + field: 'publicId', + message: `New public ID must start with "${CUSTOM_LLM_PREFIX}"`, + }, + } + : prev + ); + return; + } + + if (!displayName) { + setCopy(prev => + prev + ? { + ...prev, + validationError: { field: 'displayName', message: 'New display name is required' }, + } + : prev + ); + return; + } + + try { + await copyMutation.mutateAsync({ + source_public_id: copy.sourcePublicId, + public_id: publicId, + display_name: displayName, + }); + toast.success('Custom LLM copied'); + closeCopy(); + } catch (error) { + setCopy(prev => + prev + ? { + ...prev, + validationError: { field: null, message: formatZodError(error) }, + } + : prev + ); + } + }, [copy, copyMutation, closeCopy]); + const handleSave = useCallback(async () => { const trimmedPublicId = editor.publicId.trim(); if (!trimmedPublicId) { @@ -235,9 +327,20 @@ export function CustomLlmsContent() { variant="outline" size="sm" onClick={() => openEdit(item.public_id, item.definition)} + aria-label={`Edit ${item.public_id}`} + title="Edit custom LLM" > + handleDelete(item.public_id)} isLoading={deleteMutation.isPending} @@ -362,6 +465,86 @@ export function CustomLlmsContent() { + + { + if (!open && !copyMutation.isPending) closeCopy(); + }} + > + + + Copy Custom LLM + + Copy the definition and encrypted credentials from{' '} + {copy?.sourcePublicId}. Enter a new public ID and + display name for the copy. + + + +
+
+ + + setCopy(prev => + prev ? { ...prev, publicId: event.target.value, validationError: null } : prev + ) + } + placeholder={`e.g. ${CUSTOM_LLM_PREFIX}my-copied-model`} + className="font-mono" + aria-invalid={copy?.validationError?.field === 'publicId'} + aria-describedby={ + copy?.validationError?.field === 'publicId' ? 'copy-validation-error' : undefined + } + /> +
+ +
+ + + setCopy(prev => + prev + ? { ...prev, displayName: event.target.value, validationError: null } + : prev + ) + } + placeholder="e.g. My copied model" + aria-invalid={copy?.validationError?.field === 'displayName'} + aria-describedby={ + copy?.validationError?.field === 'displayName' + ? 'copy-validation-error' + : undefined + } + /> +
+ + {copy?.validationError && ( + + )} +
+ + + + + +
+
); } diff --git a/apps/web/src/routers/admin/custom-llm-router.test.ts b/apps/web/src/routers/admin/custom-llm-router.test.ts index 28cbfa1544..bed05b1934 100644 --- a/apps/web/src/routers/admin/custom-llm-router.test.ts +++ b/apps/web/src/routers/admin/custom-llm-router.test.ts @@ -183,6 +183,83 @@ describe('adminCustomLlmRouter', () => { }); }); + describe('copy', () => { + it('copies a custom LLM with its encrypted credentials and a new ID and name', async () => { + const caller = await createCallerForUser(admin.id); + const sourcePublicId = 'kilo-internal/test-model-copy-source'; + const copiedPublicId = 'kilo-internal/test-model-copy-target'; + + await caller.admin.customLlm.upsert({ + public_id: sourcePublicId, + definition: validDefinition, + credentials: { type: 'api_key', api_key: 'sk-secret-to-copy' }, + }); + + const result = await caller.admin.customLlm.copy({ + source_public_id: sourcePublicId, + public_id: copiedPublicId, + display_name: 'Copied GPT-4', + }); + + expect(result).toEqual({ + public_id: copiedPublicId, + definition: { + ...validDefinition, + display_name: 'Copied GPT-4', + }, + }); + + const [sourceRow] = await db + .select() + .from(custom_llm2) + .where(eq(custom_llm2.public_id, sourcePublicId)); + const [copiedRow] = await db + .select() + .from(custom_llm2) + .where(eq(custom_llm2.public_id, copiedPublicId)); + + expect(copiedRow?.definition).toEqual({ + ...validDefinition, + display_name: 'Copied GPT-4', + }); + expect(copiedRow?.encrypted_api_key).toEqual(sourceRow?.encrypted_api_key); + expect((result as Record).encrypted_api_key).toBeUndefined(); + }); + + it('does not overwrite a custom LLM with the requested new ID', async () => { + const caller = await createCallerForUser(admin.id); + const sourcePublicId = 'kilo-internal/test-model-copy-conflict-source'; + const existingPublicId = 'kilo-internal/test-model-copy-conflict-target'; + + await caller.admin.customLlm.upsert({ + public_id: sourcePublicId, + definition: validDefinition, + credentials: { type: 'api_key', api_key: 'sk-source-secret' }, + }); + await caller.admin.customLlm.upsert({ + public_id: existingPublicId, + definition: { ...validDefinition, display_name: 'Existing model' }, + credentials: { type: 'api_key', api_key: 'sk-existing-secret' }, + }); + + await expect( + caller.admin.customLlm.copy({ + source_public_id: sourcePublicId, + public_id: existingPublicId, + display_name: 'Should not overwrite', + }) + ).rejects.toMatchObject({ + code: 'CONFLICT', + }); + + const [existingRow] = await db + .select() + .from(custom_llm2) + .where(eq(custom_llm2.public_id, existingPublicId)); + expect(existingRow?.definition.display_name).toBe('Existing model'); + }); + }); + describe('delete', () => { it('deletes a custom LLM by public_id', async () => { const caller = await createCallerForUser(admin.id); diff --git a/apps/web/src/routers/admin/custom-llm-router.ts b/apps/web/src/routers/admin/custom-llm-router.ts index c5d06ca569..3b6e019b90 100644 --- a/apps/web/src/routers/admin/custom-llm-router.ts +++ b/apps/web/src/routers/admin/custom-llm-router.ts @@ -24,6 +24,12 @@ const UpsertCustomLlmSchema = z.object({ credentials: CustomLlmCredentialsSchema.optional(), }); +const CopyCustomLlmSchema = z.object({ + source_public_id: publicIdSchema, + public_id: publicIdSchema, + display_name: z.string().trim().min(1, 'display_name is required'), +}); + const DeleteCustomLlmSchema = z.object({ public_id: publicIdSchema, }); @@ -95,6 +101,44 @@ export const adminCustomLlmRouter = createTRPCRouter({ return inserted; }), + copy: adminProcedure.input(CopyCustomLlmSchema).mutation(async ({ input }) => { + const source = await db.query.custom_llm2.findFirst({ + where: eq(custom_llm2.public_id, input.source_public_id), + }); + + if (!source) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Custom LLM with public_id "${input.source_public_id}" not found`, + }); + } + + const [inserted] = await db + .insert(custom_llm2) + .values({ + public_id: input.public_id, + definition: { + ...source.definition, + display_name: input.display_name, + }, + encrypted_api_key: source.encrypted_api_key, + }) + .onConflictDoNothing() + .returning({ + public_id: custom_llm2.public_id, + definition: custom_llm2.definition, + }); + + if (!inserted) { + throw new TRPCError({ + code: 'CONFLICT', + message: `Custom LLM with public_id "${input.public_id}" already exists`, + }); + } + + return inserted; + }), + delete: adminProcedure.input(DeleteCustomLlmSchema).mutation(async ({ input }) => { const result = await db.delete(custom_llm2).where(eq(custom_llm2.public_id, input.public_id));