From 8546e430c058feb6b9a7507f9f1ef61b486fa64f Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:47:42 +0000 Subject: [PATCH 1/5] feat(ai-gateway): support public custom LLMs --- .../admin/custom-llms/CustomLlmsContent.tsx | 211 +++++++++++++----- .../public-custom-llm-snapshot.test.ts | 58 +++++ .../openrouter/public-custom-llm-snapshot.ts | 47 ++++ .../providers/openrouter/sync-providers.ts | 27 ++- .../routers/admin/custom-llm-router.test.ts | 83 ++++++- .../src/routers/admin/custom-llm-router.ts | 13 +- apps/web/src/tests/openrouter-models.test.ts | 52 +++++ packages/db/src/schema-types.ts | 69 +++++- .../src/lib/ai-gateway/custom-llm/access.ts | 2 +- .../custom-llm/custom-llm-catalog.test.ts | 48 +++- .../custom-llm/custom-llm-catalog.ts | 55 ++++- .../custom-llm/listAvailableCustomLlms.ts | 32 ++- .../lib/ai-gateway/handlers/llm-proxy.test.ts | 164 +++++++++++++- .../src/lib/ai-gateway/handlers/llm-proxy.ts | 31 ++- .../src/lib/ai-gateway/llm-proxy-helpers.ts | 13 +- .../src/lib/ai-gateway/processUsage.ts | 5 +- .../apply-provider-specific-logic.ts | 4 +- .../get-provider.public-custom-llm.test.ts | 100 +++++++++ .../lib/ai-gateway/providers/get-provider.ts | 93 ++++---- .../ai-gateway/providers/openrouter/index.ts | 56 ++++- .../src/lib/model-allow.server.test.ts | 35 ++- .../web-shared/src/lib/model-allow.server.ts | 21 +- .../effective-model-access.server.test.ts | 31 +++ .../model-access/model-access.server.ts | 6 +- .../src/tests/helpers/custom-llm.helper.ts | 26 ++- 25 files changed, 1098 insertions(+), 184 deletions(-) create mode 100644 apps/web/src/lib/providers/openrouter/public-custom-llm-snapshot.test.ts create mode 100644 apps/web/src/lib/providers/openrouter/public-custom-llm-snapshot.ts create mode 100644 packages/web-shared/src/lib/ai-gateway/providers/get-provider.public-custom-llm.test.ts diff --git a/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx b/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx index fe23aa97cdb..288e00c97c9 100644 --- a/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx +++ b/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx @@ -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, @@ -46,6 +56,7 @@ type EditorState = { open: boolean; mode: 'create' | 'edit'; publicId: string; + wasPublic: boolean; credentialsJson: string; definitionJson: string; validationError: string | null; @@ -53,6 +64,7 @@ type EditorState = { type CopyState = { sourcePublicId: string; + sourceIsPublic: boolean; publicId: string; displayName: string; internalId: string; @@ -77,10 +89,28 @@ 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; +}; + +type PendingPublicChange = + | { kind: 'upsert'; input: UpsertInput } + | { kind: 'copy'; input: CopyInput }; + 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 +123,14 @@ export function CustomLlmsContent() { const deleteMutation = useDeleteCustomLlm(); const [editor, setEditor] = useState(initialEditorState); const [copy, setCopy] = useState(null); + const [pendingPublicChange, setPendingPublicChange] = useState(null); 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 +142,7 @@ export function CustomLlmsContent() { open: true, mode: 'edit', publicId, + wasPublic: definition.public !== undefined, credentialsJson: '', definitionJson: JSON.stringify(definition, null, 2), validationError: null, @@ -120,23 +153,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 +245,18 @@ 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, + }; + if (copy.sourceIsPublic) { + setPendingPublicChange({ kind: 'copy', input }); + return; } - }, [copy, copyMutation, closeCopy]); + await submitCopy(input); + }, [copy, submitCopy]); const handleSave = useCallback(async () => { const trimmedPublicId = editor.publicId.trim(); @@ -248,7 +304,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 +322,24 @@ 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, + }; + if (input.definition.public !== undefined && !editor.wasPublic) { + setPendingPublicChange({ kind: 'upsert', input }); + return; } - }, [editor, upsertMutation, closeEditor]); + await submitUpsert(input); + }, [editor, submitUpsert]); + + const confirmPublicChange = useCallback(async () => { + const change = pendingPublicChange; + setPendingPublicChange(null); + if (change?.kind === 'upsert') await submitUpsert(change.input); + if (change?.kind === 'copy') await submitCopy(change.input); + }, [pendingPublicChange, submitUpsert, submitCopy]); const handleDelete = useCallback( async (publicId: string) => { @@ -312,6 +378,7 @@ export function CustomLlmsContent() { Public ID Display Name + Access Internal ID Actions @@ -319,7 +386,7 @@ export function CustomLlmsContent() { {data?.items.length === 0 && ( - + No custom LLMs defined yet. @@ -328,6 +395,7 @@ export function CustomLlmsContent() { {item.public_id} {item.definition.display_name} + {item.definition.public ? 'Public' : 'Private'} {item.definition.internal_id ?? 'Not set'} @@ -345,13 +413,7 @@ export function CustomLlmsContent() {