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-14 11:32:11 +02:00
1 parent b8f360c04e
commit 45d8bc9443
77 files changed
+4035 -763

No files matched your search

@@ -5,7 +5,7 @@ import { assistantExecutionPolicy } from "./assistant-execution-policy";
describe("assistantExecutionPolicy", () => {
test("allows a sizeable batch of tool calls to finish in one request", () => {
expect(assistantExecutionPolicy.maxSteps).toBeGreaterThanOrEqual(32);
expect(assistantExecutionPolicy.maxOutputTokens).toBeGreaterThanOrEqual(65_536);
expect(assistantExecutionPolicy.maxOutputTokens).toBe(32_768);
expect(assistantExecutionPolicy.totalTimeoutMs).toBeGreaterThan(assistantExecutionPolicy.stepTimeoutMs);
expect(assistantExecutionPolicy.toolTimeoutMs).toBeGreaterThanOrEqual(60_000);
});
@@ -1,6 +1,6 @@
export const assistantExecutionPolicy = {
maxSteps: 32,
maxOutputTokens: 65_536,
maxOutputTokens: 32_768,
totalTimeoutMs: 240_000,
stepTimeoutMs: 60_000,
toolTimeoutMs: 60_000,
@@ -1,6 +1,12 @@
import { describe, expect, test } from "vitest";
import { toProviderOptionsKey } from "./assistant-provider-options";
import { assistantHomarrProviderTokenHeader } from "@homarr/definitions";
import {
getHomarrProviderBaseUrl,
resolveHomarrProviderToken,
toProviderOptionsKey,
} from "./assistant-provider-options";
describe("toProviderOptionsKey", () => {
test("converts the hyphenated provider name to the camelCase AI SDK provider option key", () => {
@@ -16,3 +22,55 @@ describe("toProviderOptionsKey", () => {
expect(toProviderOptionsKey("homarr")).toBe("homarr");
});
});
describe("resolveHomarrProviderToken", () => {
test("accepts a Workshop token only for the matching Homarr provider endpoint", () => {
const headers = new Headers({ [assistantHomarrProviderTokenHeader]: "workshop-token" });
expect(
resolveHomarrProviderToken({
provider: "homarr",
configuredBaseUrl: "https://homarr.dev/api/ai/v1/",
workshopApiUrl: "https://homarr.dev",
headers,
}),
).toBe("workshop-token");
expect(getHomarrProviderBaseUrl("https://homarr.dev/")).toBe("https://homarr.dev/api/ai/v1");
});
test.each([
[undefined, null],
["", null],
[" ", null],
["a".repeat(4096), "a".repeat(4096)],
["a".repeat(4097), null],
])("validates the Workshop token header %s", (token, expected) => {
const headers = new Headers(token === undefined ? undefined : { [assistantHomarrProviderTokenHeader]: token });
expect(
resolveHomarrProviderToken({
provider: "homarr",
configuredBaseUrl: "https://homarr.dev/api/ai/v1",
workshopApiUrl: "https://homarr.dev",
headers,
}),
).toBe(expected);
});
test("never forwards a Workshop token to another endpoint", () => {
expect(() =>
resolveHomarrProviderToken({
provider: "homarr",
configuredBaseUrl: "https://attacker.example/v1",
workshopApiUrl: "https://homarr.dev",
headers: new Headers({ [assistantHomarrProviderTokenHeader]: "workshop-token" }),
}),
).toThrow("does not match");
expect(
resolveHomarrProviderToken({
provider: "openrouter",
configuredBaseUrl: "https://openrouter.ai/api/v1",
workshopApiUrl: "https://homarr.dev",
headers: new Headers({ [assistantHomarrProviderTokenHeader]: "workshop-token" }),
}),
).toBeUndefined();
});
});
@@ -1,3 +1,5 @@
import { assistantHomarrProviderTokenHeader } from "@homarr/definitions";
/**
* The AI SDK resolves provider options by the provider name registered on the
* language model. The @ai-sdk/openai-compatible provider converts the raw
@@ -15,3 +17,24 @@ export const toProviderOptionsKey = (name: string) =>
return segment.charAt(0).toUpperCase() + segment.slice(1);
})
.join("");
export const getHomarrProviderBaseUrl = (workshopApiUrl: string) => `${workshopApiUrl.replace(/\/+$/u, "")}/api/ai/v1`;
export const resolveHomarrProviderToken = ({
provider,
configuredBaseUrl,
workshopApiUrl,
headers,
}: {
provider: string;
configuredBaseUrl: string;
workshopApiUrl: string;
headers: Headers;
}) => {
if (provider !== "homarr") return undefined;
if (configuredBaseUrl.replace(/\/+$/u, "") !== getHomarrProviderBaseUrl(workshopApiUrl)) {
throw new Error("The Homarr provider endpoint does not match the configured Workshop server.");
}
const token = headers.get(assistantHomarrProviderTokenHeader)?.trim();
return token && token.length <= 4096 ? token : null;
};
@@ -45,7 +45,7 @@ import {
withOpenRouterToolRequestOptions,
withOpenRouterWebSearch,
} from "./assistant-openrouter";
import { toProviderOptionsKey } from "./assistant-provider-options";
import { resolveHomarrProviderToken, toProviderOptionsKey } from "./assistant-provider-options";
import { assistantExecutionPolicy } from "./assistant-execution-policy";
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
import { getSafeAssistantToolError } from "./assistant-tool-error";
@@ -72,6 +72,7 @@ export const maxDuration = 300;
const assistantRequestMaxBytes = 12_000_000;
const logger = createLogger({ module: "assistant" });
const getAssistantLogErrorType = (error: unknown) => (error instanceof Error ? error.name : typeof error);
const getToolApprovalSecret = () =>
Buffer.from(
hkdfSync("sha256", Buffer.from(env.SECRET_ENCRYPTION_KEY, "hex"), "", "assistant-tool-approval", 32),
@@ -424,6 +425,28 @@ export async function POST(request: Request) {
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
}
let homarrProviderToken: string | null | undefined;
try {
homarrProviderToken = resolveHomarrProviderToken({
provider: configuration.provider,
configuredBaseUrl: configuration.baseUrl,
workshopApiUrl: appEnv.WORKSHOP_API_URL ?? appEnv.HOMARR_WEBSITE_URL,
headers: request.headers,
});
} catch (error) {
logger.warn("Rejected an unsafe Homarr provider endpoint", {
provider: configuration.provider,
errorType: getAssistantLogErrorType(error),
});
return Response.json({ error: "The Homarr provider endpoint is not configured safely." }, { status: 503 });
}
if (homarrProviderToken === null) {
return Response.json(
{ error: "Sign in to the Homarr Community Workshop to use the Homarr provider." },
{ status: 401 },
);
}
const context = createTRPCContext({ headers: request.headers, session });
const requestStartedAt = Date.now();
const requestId = crypto.randomUUID();
@@ -440,7 +463,7 @@ export async function POST(request: Request) {
requestId,
provider: configuration.provider,
modelId: requestedModelId,
error: modelLookup.error,
errorType: getAssistantLogErrorType(modelLookup.error),
});
}
const selectedModel = modelLookup.model;
@@ -514,7 +537,7 @@ export async function POST(request: Request) {
} catch (error) {
logger.error("Assistant tool call failed", {
toolName: mcpTool.name,
error: error instanceof Error ? error.message : String(error),
errorType: getAssistantLogErrorType(error),
});
return { error: getSafeAssistantToolError(error, { toolName: mcpTool.name }) };
}
@@ -559,13 +582,18 @@ export async function POST(request: Request) {
...(configuration.provider === "openrouter" || openRouterServerToolsEnabled
? {
"HTTP-Referer": "https://homarr.dev",
"X-Title": "Homarr Assistant",
"X-OpenRouter-Title": "Homarr AI Assistant",
"X-OpenRouter-Metadata": "enabled",
}
: {}),
...customHeaders,
};
const providerApiKey = configuration.encryptedApiKey ? decryptSecret(configuration.encryptedApiKey) : undefined;
const providerApiKey =
configuration.provider === "homarr"
? homarrProviderToken
: configuration.encryptedApiKey
? decryptSecret(configuration.encryptedApiKey)
: undefined;
const providerName = `homarr-${configuration.provider}`;
const provider = createOpenAICompatible({
name: providerName,
@@ -720,7 +748,7 @@ export async function POST(request: Request) {
requestId,
provider: configuration.provider,
modelId,
error: error instanceof Error ? error.message : String(error),
errorType: getAssistantLogErrorType(error),
});
},
onFinish: async () => {
@@ -732,7 +760,7 @@ export async function POST(request: Request) {
} catch (error) {
logger.error("Failed to update assistant conversation metadata", {
threadId: thread.id,
error: error instanceof Error ? error.message : String(error),
errorType: getAssistantLogErrorType(error),
});
}
},
@@ -839,7 +867,7 @@ export async function POST(request: Request) {
});
} catch (error) {
logger.error("Assistant response could not start", {
error: error instanceof Error ? error.message : String(error),
errorType: getAssistantLogErrorType(error),
});
return Response.json(
{