From f8b000a88dff14fe85508272eb1ae26f0554286b Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:02:20 +0000 Subject: [PATCH 1/4] feat(admin): add custom LLM management panel with CRUD and JSON editor Add admin panel at /admin/custom-llms to manage custom_llm2 table entries. Includes Monaco-based JSON editor with Zod validation against CustomLlmDefinitionSchema before saving. --- src/app/admin/api/custom-llms/hooks.ts | 39 ++++ src/app/admin/components/AppSidebar.tsx | 5 + src/app/admin/custom-llms/page.tsx | 298 ++++++++++++++++++++++++ src/routers/admin-router.ts | 2 + src/routers/admin/custom-llm-router.ts | 62 +++++ 5 files changed, 406 insertions(+) create mode 100644 src/app/admin/api/custom-llms/hooks.ts create mode 100644 src/app/admin/custom-llms/page.tsx create mode 100644 src/routers/admin/custom-llm-router.ts diff --git a/src/app/admin/api/custom-llms/hooks.ts b/src/app/admin/api/custom-llms/hooks.ts new file mode 100644 index 000000000..6eecfcac9 --- /dev/null +++ b/src/app/admin/api/custom-llms/hooks.ts @@ -0,0 +1,39 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; + +export function useCustomLlms() { + const trpc = useTRPC(); + return useQuery(trpc.admin.customLlm.list.queryOptions()); +} + +export function useUpsertCustomLlm() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + trpc.admin.customLlm.upsert.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.admin.customLlm.list.queryKey(), + }); + }, + }) + ); +} + +export function useDeleteCustomLlm() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + trpc.admin.customLlm.delete.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: trpc.admin.customLlm.list.queryKey(), + }); + }, + }) + ); +} diff --git a/src/app/admin/components/AppSidebar.tsx b/src/app/admin/components/AppSidebar.tsx index 956791b24..709bf6b5b 100644 --- a/src/app/admin/components/AppSidebar.tsx +++ b/src/app/admin/components/AppSidebar.tsx @@ -162,6 +162,11 @@ const productEngineeringItems: MenuItem[] = [ url: '/admin/sync-providers', icon: () => , }, + { + title: () => 'Custom LLMs', + url: '/admin/custom-llms', + icon: () => , + }, ]; const analyticsObservabilityItems: MenuItem[] = [ diff --git a/src/app/admin/custom-llms/page.tsx b/src/app/admin/custom-llms/page.tsx new file mode 100644 index 000000000..76de3f808 --- /dev/null +++ b/src/app/admin/custom-llms/page.tsx @@ -0,0 +1,298 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import AdminPage from '@/app/admin/components/AdminPage'; +import { BreadcrumbItem, BreadcrumbPage } from '@/components/ui/breadcrumb'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { InlineDeleteConfirmation } from '@/components/ui/inline-delete-confirmation'; +import { + useCustomLlms, + useUpsertCustomLlm, + useDeleteCustomLlm, +} from '@/app/admin/api/custom-llms/hooks'; +import { CustomLlmDefinitionSchema } from '@kilocode/db/schema-types'; +import type { CustomLlmDefinition } from '@kilocode/db/schema-types'; +import { toast } from 'sonner'; +import { Plus, Pencil } from 'lucide-react'; +import Editor from '@monaco-editor/react'; + +type EditorState = { + open: boolean; + mode: 'create' | 'edit'; + publicId: string; + definitionJson: string; + validationError: string | null; +}; + +const INITIAL_DEFINITION: CustomLlmDefinition = { + internal_id: '', + display_name: '', + context_length: 0, + max_completion_tokens: 0, + base_url: '', + api_key: '', + organization_ids: [], +}; + +const initialEditorState: EditorState = { + open: false, + mode: 'create', + publicId: '', + definitionJson: JSON.stringify(INITIAL_DEFINITION, null, 2), + validationError: null, +}; + +export default function AdminCustomLlmsPage() { + const { data, isLoading } = useCustomLlms(); + const upsertMutation = useUpsertCustomLlm(); + const deleteMutation = useDeleteCustomLlm(); + const [editor, setEditor] = useState(initialEditorState); + + const openCreate = useCallback(() => { + setEditor({ + open: true, + mode: 'create', + publicId: '', + definitionJson: JSON.stringify(INITIAL_DEFINITION, null, 2), + validationError: null, + }); + }, []); + + const openEdit = useCallback((publicId: string, definition: CustomLlmDefinition) => { + setEditor({ + open: true, + mode: 'edit', + publicId, + definitionJson: JSON.stringify(definition, null, 2), + validationError: null, + }); + }, []); + + const closeEditor = useCallback(() => { + setEditor(initialEditorState); + }, []); + + const handleSave = useCallback(async () => { + if (!editor.publicId.trim()) { + setEditor(prev => ({ ...prev, validationError: 'public_id is required' })); + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(editor.definitionJson); + } catch { + setEditor(prev => ({ ...prev, validationError: 'Invalid JSON syntax' })); + return; + } + + const result = CustomLlmDefinitionSchema.safeParse(parsed); + if (!result.success) { + const messages = result.error.issues + .map(issue => `${issue.path.join('.')}: ${issue.message}`) + .join('\n'); + setEditor(prev => ({ ...prev, validationError: messages })); + return; + } + + try { + await upsertMutation.mutateAsync({ + public_id: editor.publicId, + definition: result.data, + }); + toast.success(editor.mode === 'create' ? 'Custom LLM created' : 'Custom LLM updated'); + closeEditor(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to save'); + } + }, [editor, upsertMutation, closeEditor]); + + const handleDelete = useCallback( + async (publicId: string) => { + try { + await deleteMutation.mutateAsync({ public_id: publicId }); + toast.success('Custom LLM deleted'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete'); + } + }, + [deleteMutation] + ); + + const breadcrumbs = ( + + Custom LLMs + + ); + + return ( + +
+
+

Custom LLMs

+ +
+ +

+ Manage custom LLM definitions stored in the custom_llm2 table. Each entry has + a public_id and a JSON definition that is validated against{' '} + CustomLlmDefinitionSchema. +

+ + {isLoading ? ( +
Loading...
+ ) : ( + + + + Public ID + Display Name + Internal ID + Base URL + Organizations + Actions + + + + {data?.items.length === 0 && ( + + + No custom LLMs defined yet. + + + )} + {data?.items.map(item => ( + + {item.public_id} + {item.definition.display_name} + {item.definition.internal_id} + + {item.definition.base_url} + + + {item.definition.organization_ids.length > 0 + ? item.definition.organization_ids.join(', ') + : '-'} + + +
+ + handleDelete(item.public_id)} + isLoading={deleteMutation.isPending} + /> +
+
+
+ ))} +
+
+ )} + + { + if (!open) closeEditor(); + }} + > + + + + {editor.mode === 'create' ? 'Add Custom LLM' : `Edit: ${editor.publicId}`} + + + +
+
+ + + setEditor(prev => ({ + ...prev, + publicId: e.target.value, + validationError: null, + })) + } + disabled={editor.mode === 'edit'} + placeholder="e.g. my-custom-model" + className="font-mono" + /> +
+ +
+ +
+ + setEditor(prev => ({ + ...prev, + definitionJson: value ?? '', + validationError: null, + })) + } + theme="vs-dark" + options={{ + minimap: { enabled: false }, + fontSize: 13, + lineNumbers: 'on', + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + formatOnPaste: true, + }} + /> +
+
+ + {editor.validationError && ( +
+                  {editor.validationError}
+                
+ )} +
+ + + + + +
+
+
+
+ ); +} diff --git a/src/routers/admin-router.ts b/src/routers/admin-router.ts index 96413cfd6..ed169d7cb 100644 --- a/src/routers/admin-router.ts +++ b/src/routers/admin-router.ts @@ -32,6 +32,7 @@ import { bulkUserCreditsRouter } from '@/routers/admin/bulk-user-credits-router' import { emailTestingRouter } from '@/routers/admin/email-testing-router'; import { adminGastownRouter } from '@/routers/admin/gastown-router'; import { extendClawTrialRouter } from '@/routers/admin/extend-claw-trial-router'; +import { adminCustomLlmRouter } from '@/routers/admin/custom-llm-router'; import { adminWebhookTriggersRouter } from '@/routers/admin-webhook-triggers-router'; import { adminAlertingRouter } from '@/routers/admin-alerting-router'; import { adminBotRequestsRouter } from '@/routers/admin-bot-requests-router'; @@ -1358,4 +1359,5 @@ export const adminRouter = createTRPCRouter({ botRequests: adminBotRequestsRouter, gastown: adminGastownRouter, extendClawTrial: extendClawTrialRouter, + customLlm: adminCustomLlmRouter, }); diff --git a/src/routers/admin/custom-llm-router.ts b/src/routers/admin/custom-llm-router.ts new file mode 100644 index 000000000..dc13a41c4 --- /dev/null +++ b/src/routers/admin/custom-llm-router.ts @@ -0,0 +1,62 @@ +import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { db } from '@/lib/drizzle'; +import { custom_llm2 } from '@kilocode/db/schema'; +import { CustomLlmDefinitionSchema } from '@kilocode/db/schema-types'; +import { eq } from 'drizzle-orm'; +import { TRPCError } from '@trpc/server'; +import * as z from 'zod'; + +const UpsertCustomLlmSchema = z.object({ + public_id: z.string().min(1, 'public_id is required'), + definition: CustomLlmDefinitionSchema, +}); + +const DeleteCustomLlmSchema = z.object({ + public_id: z.string().min(1), +}); + +export const adminCustomLlmRouter = createTRPCRouter({ + list: adminProcedure.query(async () => { + const rows = await db.select().from(custom_llm2); + return { items: rows }; + }), + + upsert: adminProcedure.input(UpsertCustomLlmSchema).mutation(async ({ input }) => { + const existing = await db.query.custom_llm2.findFirst({ + where: eq(custom_llm2.public_id, input.public_id), + }); + + if (existing) { + const [updated] = await db + .update(custom_llm2) + .set({ definition: input.definition }) + .where(eq(custom_llm2.public_id, input.public_id)) + .returning(); + + return updated; + } + + const [inserted] = await db + .insert(custom_llm2) + .values({ + public_id: input.public_id, + definition: input.definition, + }) + .returning(); + + 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)); + + if ((result.rowCount ?? 0) === 0) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Custom LLM with public_id "${input.public_id}" not found`, + }); + } + + return { success: true }; + }), +}); From 96400ddca9dc798309cce6219233d8d7ffb2ee1b Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:31:56 +0000 Subject: [PATCH 2/4] fix: enforce kilo-internal/ prefix on public_id The provider routing code only resolves custom_llm2 entries when the requested model starts with "kilo-internal/". Enforce this at the Zod validation layer so admins cannot save unusable entries. --- src/app/admin/custom-llms/page.tsx | 2 +- src/routers/admin/custom-llm-router.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/admin/custom-llms/page.tsx b/src/app/admin/custom-llms/page.tsx index 76de3f808..3d002b2b8 100644 --- a/src/app/admin/custom-llms/page.tsx +++ b/src/app/admin/custom-llms/page.tsx @@ -242,7 +242,7 @@ export default function AdminCustomLlmsPage() { })) } disabled={editor.mode === 'edit'} - placeholder="e.g. my-custom-model" + placeholder="e.g. kilo-internal/my-custom-model" className="font-mono" /> diff --git a/src/routers/admin/custom-llm-router.ts b/src/routers/admin/custom-llm-router.ts index dc13a41c4..54a215fbc 100644 --- a/src/routers/admin/custom-llm-router.ts +++ b/src/routers/admin/custom-llm-router.ts @@ -6,13 +6,20 @@ import { eq } from 'drizzle-orm'; import { TRPCError } from '@trpc/server'; import * as z from 'zod'; +const KILO_INTERNAL_PREFIX = 'kilo-internal/'; + +const publicIdSchema = z + .string() + .min(1, 'public_id is required') + .startsWith(KILO_INTERNAL_PREFIX, `public_id must start with "${KILO_INTERNAL_PREFIX}"`); + const UpsertCustomLlmSchema = z.object({ - public_id: z.string().min(1, 'public_id is required'), + public_id: publicIdSchema, definition: CustomLlmDefinitionSchema, }); const DeleteCustomLlmSchema = z.object({ - public_id: z.string().min(1), + public_id: publicIdSchema, }); export const adminCustomLlmRouter = createTRPCRouter({ From 1673ab2b597d3a90dc49854a2634ac6c33e159a8 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:34:08 +0000 Subject: [PATCH 3/4] chore: remove organizations column from custom LLMs table --- src/app/admin/custom-llms/page.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/app/admin/custom-llms/page.tsx b/src/app/admin/custom-llms/page.tsx index 3d002b2b8..5043ba707 100644 --- a/src/app/admin/custom-llms/page.tsx +++ b/src/app/admin/custom-llms/page.tsx @@ -169,14 +169,13 @@ export default function AdminCustomLlmsPage() { Display Name Internal ID Base URL - Organizations Actions {data?.items.length === 0 && ( - + No custom LLMs defined yet. @@ -189,11 +188,6 @@ export default function AdminCustomLlmsPage() { {item.definition.base_url} - - {item.definition.organization_ids.length > 0 - ? item.definition.organization_ids.join(', ') - : '-'} -