Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/web/src/app/admin/api/custom-llms/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
185 changes: 184 additions & 1 deletion apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
Expand All @@ -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';
Expand All @@ -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);
Expand All @@ -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: '',
Expand Down Expand Up @@ -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<EditorState>(initialEditorState);
const [copy, setCopy] = useState<CopyState | null>(null);

const openCreate = useCallback(() => {
setEditor({
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openCopy(item.public_id, item.definition.display_name)}
aria-label={`Copy ${item.public_id}`}
title="Copy custom LLM"
>
<CopyIcon className="h-3 w-3" />
</Button>
<InlineDeleteConfirmation
onDelete={() => handleDelete(item.public_id)}
isLoading={deleteMutation.isPending}
Expand Down Expand Up @@ -362,6 +465,86 @@ export function CustomLlmsContent() {
</DialogFooter>
</DialogContent>
</Dialog>

<Dialog
open={copy !== null}
onOpenChange={open => {
if (!open && !copyMutation.isPending) closeCopy();
}}
>
<DialogContent showCloseButton={!copyMutation.isPending}>
<DialogHeader>
<DialogTitle>Copy Custom LLM</DialogTitle>
<DialogDescription>
Copy the definition and encrypted credentials from{' '}
<code className="font-mono">{copy?.sourcePublicId}</code>. Enter a new public ID and
display name for the copy.
</DialogDescription>
</DialogHeader>

<div className="flex flex-col gap-4">
<div>
<Label htmlFor="copy-public-id">New Public ID</Label>
<Input
id="copy-public-id"
value={copy?.publicId ?? ''}
onChange={event =>
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
}
/>
</div>

<div>
<Label htmlFor="copy-display-name">New Display Name</Label>
<Input
id="copy-display-name"
value={copy?.displayName ?? ''}
onChange={event =>
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
}
/>
</div>

{copy?.validationError && (
<p
id="copy-validation-error"
className="bg-destructive/10 text-destructive rounded-md p-3 text-sm"
role="alert"
>
{copy.validationError.message}
</p>
)}
</div>

<DialogFooter>
<Button variant="outline" onClick={closeCopy} disabled={copyMutation.isPending}>
Cancel
</Button>
<Button onClick={handleCopy} disabled={copyMutation.isPending}>
{copyMutation.isPending ? 'Copying...' : 'Copy Custom LLM'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
77 changes: 77 additions & 0 deletions apps/web/src/routers/admin/custom-llm-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).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);
Expand Down
Loading