import { observable } from "@trpc/server/observable"; import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { createIntegrationAsync } from "@homarr/integrations"; import { openWebUiChatRequestHandler, openWebUiChatsRequestHandler, openWebUiFilesRequestHandler, openWebUiKnowledgeFilesRequestHandler, openWebUiKnowledgeRequestHandler, openWebUiModelsRequestHandler, openWebUiNoteRequestHandler, openWebUiNotesRequestHandler, } from "@homarr/request-handler/open-webui"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const logger = createLogger({ module: "openWebUiRouter" }); /** * Open WebUI integrations hold a personal API key that grants access to a * single user's data (chat history, files, notes, knowledge bases). To prevent * other Homarr users from reading that data, every procedure that touches the * integration verifies that the caller is the integration's creator. * * The check is intentionally placed **after** the integration middleware (which * already validates the caller's permission level) so the error message does * not leak whether the integration exists. */ const throwIfNotCreator = (integration: { creatorId?: string | null }, userId: string | undefined) => { // Existing integrations that predate the creatorId column have a null // creatorId; we skip the check for backward compatibility. if (integration.creatorId && integration.creatorId !== userId) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the integration creator can access this data" }); } }; // Hosts that should never be fetched server-side. processWeb is already // creator-gated, but we still refuse loopback and the cloud metadata endpoint so // a stray URL can't turn Open WebUI's fetcher into an SSRF vector. Private LAN // ranges stay allowed on purpose (self-hosters routinely ingest internal wikis). const isBlockedWebHost = (hostname: string): boolean => { const host = hostname.toLowerCase().replace(/^\[|\]$/g, ""); return ( host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "::" || host === "::1" || host.startsWith("127.") || host === "169.254.169.254" || // AWS/GCP/Azure instance metadata host === "metadata.google.internal" ); }; // A user-provided URL for web-page ingestion: must be http(s) and not target a // loopback/metadata host. const webUrlSchema = z .string() .url() .refine((value) => { let parsed: URL; try { parsed = new URL(value); } catch { return false; } return (parsed.protocol === "http:" || parsed.protocol === "https:") && !isBlockedWebHost(parsed.hostname); }, "URL must be an http(s) address and must not point at a loopback or metadata host"); // Roughly 18 MB of raw bytes once base64-decoded (base64 inflates by ~4/3). // Bounds the upload/transcription payloads so a huge request can't pressure // memory here or upstream before we even start decoding. const MAX_BASE64_LENGTH = 25_000_000; const boundedBase64Schema = z.string().min(1).max(MAX_BASE64_LENGTH); const uploadFilenameSchema = z.string().min(1).max(255); const contentTypeSchema = z.string().min(1).max(255); // Tag persisted conversations with the owning Homarr user so they can be found // back in Open WebUI even when an integration/account is shared between users. const homarrUserTag = (userId: string) => `homarr-user:${userId}`; const messageSchema = z.object({ role: z.enum(["system", "user", "assistant"]), content: z.string(), }); // Outbound completion messages may carry multimodal parts (text + images), // so users can attach images to a message. const contentPartSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("text"), text: z.string() }), z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }), ]); const completionMessageSchema = z.object({ role: z.enum(["system", "user", "assistant"]), content: z.union([z.string(), z.array(contentPartSchema)]), }); const chatPayloadSchema = z.object({ title: z.string(), models: z.array(z.string()), messages: z.array(messageSchema), }); type StreamEvent = { type: "delta"; content: string } | { type: "done" } | { type: "error"; message: string }; type CompletionMessage = z.infer; // Extract the plain text of a message, flattening multimodal content parts. const messageText = (message: CompletionMessage): string => typeof message.content === "string" ? message.content : message.content .filter((part): part is { type: "text"; text: string } => part.type === "text") .map((part) => part.text) .join(" "); // Number of chunks to retrieve per collection when grounding a reply. const RETRIEVAL_K = 4; // Instruction prepended to the retrieved context so the model knows how to use // it. Model-facing, so intentionally not localized. const RETRIEVAL_INSTRUCTION = "Use the following context from the user's attached sources (web pages, knowledge bases, " + "files, notes and chats) to answer. If the answer is not in the context, say so."; // Retrieve relevant chunks from the attached collections and prepend them as a // system context message so the model can ground its reply on them. const groundMessagesAsync = async ( client: { queryCollectionsAsync: (names: string[], query: string, k?: number) => Promise }, messages: CompletionMessage[], collections: string[], contextTexts: string[], ): Promise => { if (collections.length === 0 && contextTexts.length === 0) return messages; // Use the most recent user message that has text. The latest turn can be // image-only (no text), in which case we fall back to the prior question so // attached collections still get a meaningful retrieval query. const query = messages .filter((message) => message.role === "user") .map(messageText) .findLast((text) => text.trim() !== "") ?? ""; let documents: string[] = []; if (collections.length > 0) { try { documents = await client.queryCollectionsAsync(collections, query, RETRIEVAL_K); } catch (error) { logger.warn("Open WebUI context retrieval failed; continuing without it", { error }); } } const blocks = [...documents, ...contextTexts].filter((block) => block.trim() !== ""); if (blocks.length === 0) return messages; const contextMessage: CompletionMessage = { role: "system", content: `${RETRIEVAL_INSTRUCTION}\n\n${blocks.join("\n\n")}`, }; return [contextMessage, ...messages]; }; export const openWebUiRouter = createTRPCRouter({ getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => { throwIfNotCreator(ctx.integration, ctx.session?.user.id); const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {}); const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return data; }), // Chat history exposes private user data, so we require "interact" access // (not just "query") and verify the caller is the integration's creator. getChats: protectedProcedure .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .query(async ({ ctx }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {}); const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return data; }), // Knowledge bases, files and notes expose private source content/metadata, so // gate them like the interactive chat paths (protected + "interact" access, // creator-only) instead of leaving them reachable by anonymous board viewers. getKnowledge: protectedProcedure .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .query(async ({ ctx }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {}); const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return data; }), getFiles: protectedProcedure .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .query(async ({ ctx }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {}); const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return data; }), getNotes: protectedProcedure .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .query(async ({ ctx }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {}); const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return data; }), getNote: protectedProcedure .input(z.object({ noteId: z.string() })) .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .query(async ({ ctx, input }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId }); const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return data; }), getKnowledgeFiles: protectedProcedure .input(z.object({ knowledgeId: z.string() })) .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .query(async ({ ctx, input }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, { knowledgeId: input.knowledgeId, }); const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return data; }), // Ingest a web page so its content can ground later messages. Returns the // collection to retrieve from plus a display title. processWeb: protectedProcedure .input(z.object({ url: webUrlSchema })) .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .mutation(async ({ ctx, input }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const client = await createIntegrationAsync(ctx.integration); return await client.processWebAsync(input.url); }), // Upload a file (e.g. a document) so it can be retrieved from later. Returns // the file id; its vector collection is `file-{id}`. uploadFile: protectedProcedure .input( z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }), ) .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .mutation(async ({ ctx, input }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const client = await createIntegrationAsync(ctx.integration); return await client.uploadFileAsync(input.filename, input.contentBase64, input.contentType); }), // Transcribe recorded audio to text for voice messages. transcribe: protectedProcedure .input( z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }), ) .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .mutation(async ({ ctx, input }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const client = await createIntegrationAsync(ctx.integration); const text = await client.transcribeAudioAsync(input.filename, input.contentBase64, input.contentType); return { text }; }), // Full chat messages contain private user data; restrict to the creator. getChat: protectedProcedure .input(z.object({ chatId: z.string() })) .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .query(async ({ ctx, input }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const handler = openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId }); const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); return data; }), createChat: protectedProcedure .input(chatPayloadSchema) .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .mutation(async ({ ctx, input }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const client = await createIntegrationAsync(ctx.integration); const chat = await client.createChatAsync({ title: input.title, models: input.models, messages: input.messages, meta: { tags: [homarrUserTag(ctx.session.user.id)] }, }); await openWebUiChatsRequestHandler.handler(ctx.integration, {}).invalidateAsync(); return chat; }), updateChat: protectedProcedure .input(z.object({ chatId: z.string(), chat: chatPayloadSchema })) .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .mutation(async ({ ctx, input }) => { throwIfNotCreator(ctx.integration, ctx.session.user.id); const client = await createIntegrationAsync(ctx.integration); const chat = await client.updateChatAsync(input.chatId, { ...input.chat, meta: { tags: [homarrUserTag(ctx.session.user.id)] }, }); await openWebUiChatsRequestHandler.handler(ctx.integration, {}).invalidateAsync(); await openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId }).invalidateAsync(); return chat; }), // Streaming chat completion. Delivered over the WebSocket subscription link so // tokens can be emitted as they arrive. A completion spends the owner's upstream // LLM credits, so it must not be reachable by anonymous viewers of a public // board: protectedProcedure requires a session (the WS context resolves it from // the session-token cookie) and the "interact" middleware checks integration // access, matching processWeb/uploadFile/transcribe above. sendMessage: protectedProcedure .input( z.object({ model: z.string(), messages: z.array(completionMessageSchema), // Collections to ground the reply on (web pages, knowledge bases, files). // We retrieve relevant chunks and inject them before streaming. collections: z.array(z.string()).optional(), // Verbatim context blocks (notes, referenced chats) injected as-is. contextTexts: z.array(z.string()).optional(), }), ) .concat(createOneIntegrationMiddleware("interact", "openWebUi")) .subscription(({ ctx, input }) => { throwIfNotCreator(ctx.integration, ctx.session?.user.id); return observable((emit) => { const controller = new AbortController(); void (async () => { try { const client = await createIntegrationAsync(ctx.integration); const messages = await groundMessagesAsync( client, input.messages, input.collections ?? [], input.contextTexts ?? [], ); await client.streamChatCompletionAsync( { model: input.model, messages }, (delta) => emit.next({ type: "delta", content: delta }), controller.signal, ); emit.next({ type: "done" }); emit.complete(); } catch (error) { if (!controller.signal.aborted) { logger.error("Open WebUI chat completion failed", { integrationId: ctx.integration.id, error, }); emit.next({ type: "error", message: error instanceof Error ? error.message : "Failed to stream chat completion", }); } emit.complete(); } })(); return () => { controller.abort(); }; }); }), });