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
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(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user