feat: add the community-funded Homarr assistant provider (#6563)

* Add Homarr provider proxy foundation

* Complete Homarr provider integration

* Harden Homarr provider integration

* Address Homarr provider review feedback

* Address CodeRabbit provider feedback

* Localize provider errors

* Handle provider activity load failures

* docs: improve workshop and homepage

* Harden Homarr provider limits

* fix: harden the Homarr provider

* fix: address provider review feedback

* fix: clarify provider request accounting

* feat: complete the Homarr provider endpoint

* fix: terminate invalid provider streams
This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:57:03 +02:00
1 parent 1611a9033b
commit 6ea7a6ff09
77 files changed
+4051 -774

No files matched your search

+25 -13
View File
@@ -29,6 +29,7 @@ import {
import type { createTRPCContext } from "../trpc";
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
import { env } from "../env";
import { orderMessagesByParent } from "../assistant-message-order";
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
@@ -129,6 +130,9 @@ const normalizeDiscoveryPath = (value: string | null) => {
return path;
};
const getHomarrProviderBaseUrl = () =>
`${(env.WORKSHOP_API_URL ?? env.HOMARR_WEBSITE_URL).replace(/\/+$/u, "")}/api/ai/v1`;
const getConfigurationAsync = async (db: Database) => {
return await db.query.assistantConfigurations.findFirst({
where: eq(assistantConfigurations.id, configurationId),
@@ -160,7 +164,7 @@ const getProviderHeaders = (configuration: AssistantConfiguration) => {
}
if (configuration.provider === "openrouter") {
headers["HTTP-Referer"] ??= "https://homarr.dev";
headers["X-Title"] ??= "Homarr Assistant";
headers["X-OpenRouter-Title"] ??= "Homarr AI Assistant";
}
return { ...headers, ...decryptCustomHeaders(configuration.encryptedHeaders) };
};
@@ -231,7 +235,7 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
const models = (Array.isArray(body) ? body : (body.data ?? body.models ?? [])).slice(0, 1_000);
return models
.filter((model) => {
if (configuration.provider !== "openrouter") return true;
if (configuration.provider !== "openrouter" && configuration.provider !== "homarr") return true;
const parameters = Array.isArray(model.supported_parameters) ? model.supported_parameters : [];
const outputModalities = Array.isArray(model.architecture?.output_modalities)
? model.architecture.output_modalities
@@ -260,6 +264,7 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
: null;
const toolSupport =
configuration.provider === "openrouter" ||
configuration.provider === "homarr" ||
model.capabilities?.function_calling === true ||
configuration.provider === "anthropic"
? ("confirmed" as const)
@@ -479,7 +484,7 @@ export const assistantRouter = createTRPCRouter({
const configuration = await getConfigurationAsync(ctx.db);
if (isDemoMode) {
return {
provider: "homarr",
provider: "custom" as const,
defaultModelId: "homarr-assistant",
models: [
{
@@ -546,23 +551,30 @@ export const assistantRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const baseUrl = normalizeBaseUrl(input.baseUrl);
const modelDiscoveryPath = normalizeDiscoveryPath(input.modelDiscoveryPath);
const baseUrl = input.provider === "homarr" ? getHomarrProviderBaseUrl() : normalizeBaseUrl(input.baseUrl);
const modelDiscoveryPath =
input.provider === "homarr" ? "/models" : normalizeDiscoveryPath(input.modelDiscoveryPath);
const existing = await getConfigurationAsync(ctx.db);
const destinationChanged =
existing !== undefined && (existing.provider !== input.provider || existing.baseUrl !== baseUrl);
const connectionChanged =
existing !== undefined && (destinationChanged || existing.modelDiscoveryPath !== modelDiscoveryPath);
const encryptedApiKey = input.apiKey
? encryptSecret(input.apiKey)
: input.clearApiKey || destinationChanged
const encryptedApiKey =
input.provider === "homarr"
? null
: (existing?.encryptedApiKey ?? null);
const encryptedHeaders = input.customHeaders
? encryptSecret(JSON.stringify(input.customHeaders))
: input.clearCustomHeaders || destinationChanged
: input.apiKey
? encryptSecret(input.apiKey)
: input.clearApiKey || destinationChanged
? null
: (existing?.encryptedApiKey ?? null);
const encryptedHeaders =
input.provider === "homarr"
? null
: (existing?.encryptedHeaders ?? null);
: input.customHeaders
? encryptSecret(JSON.stringify(input.customHeaders))
: input.clearCustomHeaders || destinationChanged
? null
: (existing?.encryptedHeaders ?? null);
if (existing) {
await ctx.db
@@ -121,6 +121,32 @@ describe("assistantRouter.updateConnection", () => {
modelId: "example/model",
});
});
test("never stores administrator credentials for the Homarr provider", async () => {
const db = await createConfiguredAssistantAsync();
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await caller.updateConnection({
provider: "homarr",
baseUrl: "https://attacker.example/v1",
modelDiscoveryPath: "/attacker-models",
apiKey: "must-not-be-stored",
customHeaders: { "X-Secret": "must-not-be-stored" },
clearApiKey: false,
clearCustomHeaders: false,
});
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration).toMatchObject({
provider: "homarr",
baseUrl: "https://homarr.dev/api/ai/v1",
modelDiscoveryPath: "/models",
encryptedApiKey: null,
encryptedHeaders: null,
enabled: false,
modelId: null,
});
});
});
describe("assistantRouter.updateConfiguration", () => {
@@ -170,6 +196,49 @@ describe("assistantRouter.updateConfiguration", () => {
expect(configuration?.webSearchEnabled).toBe(true);
});
test("allows the Homarr OpenRouter proxy to enable web search", async () => {
const db = await createConfiguredAssistantAsync();
await db
.update(assistantConfigurations)
.set({
provider: "homarr",
baseUrl: "https://homarr.dev/api/ai/v1",
encryptedApiKey: null,
encryptedHeaders: null,
modelId: "homarr/model",
})
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "homarr/model",
name: "Homarr model",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
);
try {
await caller.updateConfiguration({
enabled: true,
modelId: "homarr/model",
webSearchEnabled: true,
});
} finally {
vi.unstubAllGlobals();
}
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration?.webSearchEnabled).toBe(true);
});
test("rejects OpenRouter server tools for an incompatible provider", async () => {
const db = await createConfiguredAssistantAsync();
await db
@@ -25,7 +25,9 @@ describe("assistant provider presets", () => {
test("requires credentials for hosted providers but not local or custom endpoints", () => {
for (const provider of assistantProviderIds) {
expect(assistantProviderRequiresApiKey(provider)).toBe(assistantProviderPresets[provider].category === "hosted");
expect(assistantProviderRequiresApiKey(provider)).toBe(
assistantProviderPresets[provider].category === "hosted" && provider !== "homarr",
);
}
});