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:
1 parent
1611a9033b
commit
6ea7a6ff09
77 files changed
+4051
-774
No files matched your search
@@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user