Harden Homarr provider integration

This commit is contained in:
ajnart committed 2026-08-11 19:47:11 +02:00
1 parent 165201632d
commit 4ebaea29fd
23 files changed
+427 -159

No files matched your search

@@ -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
@@ -16,8 +18,7 @@ export const toProviderOptionsKey = (name: string) =>
})
.join("");
export const getHomarrProviderBaseUrl = (workshopApiUrl: string) =>
`${workshopApiUrl.replace(/\/+$/u, "")}/api/ai/v1`;
export const getHomarrProviderBaseUrl = (workshopApiUrl: string) => `${workshopApiUrl.replace(/\/+$/u, "")}/api/ai/v1`;
export const resolveHomarrProviderToken = ({
provider,
@@ -37,4 +38,3 @@ export const resolveHomarrProviderToken = ({
const token = headers.get(assistantHomarrProviderTokenHeader)?.trim();
return token && token.length <= 4096 ? token : null;
};
import { assistantHomarrProviderTokenHeader } from "@homarr/definitions";
@@ -117,7 +117,7 @@ import remarkGfm from "remark-gfm";
import { useRouter } from "next/navigation";
import { clientApi, fetchApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common/hooks";
import { useTimeAgo } from "@homarr/common";
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -153,7 +153,7 @@ import { useAssistantReasoningState } from "./assistant-reasoning-state";
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
import { getToolResultPresentation } from "./assistant-tool-result";
import { getAssistantIconSearchQuery } from "./assistant-tool-label";
import { getAssistantProviderQuotaLevel } from "./assistant-provider-quota";
import { getAssistantProviderQuotaLevel, isAssistantProviderUnavailable } from "./assistant-provider-quota";
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
import { getSafeAssistantHttpUrl } from "./assistant-url";
@@ -2581,12 +2581,7 @@ const HomarrProviderQuota = () => {
return (
<Popover position="top" width={290} shadow="md" withinPortal>
<Popover.Target>
<UnstyledButton
className={classes.providerQuotaTrigger}
data-level={level}
type="button"
aria-label={label}
>
<UnstyledButton className={classes.providerQuotaTrigger} data-level={level} type="button" aria-label={label}>
{preferences.quotaLoading && !preferences.quota ? <Loader size={14} /> : <Icon size={16} aria-hidden />}
<Text component="span" size="xs" fw={650}>
{preferences.quota?.remaining ?? "–"}
@@ -2644,7 +2639,10 @@ const HomarrProviderQuota = () => {
</Box>
<Text size="xs" c="dimmed">
{t("providerQuota.reset", { relative: resetRelative })}{" "}
<time dateTime={preferences.quota.resetsAt} title={new Date(preferences.quota.resetsAt).toLocaleString()}>
<time
dateTime={preferences.quota.resetsAt}
title={new Date(preferences.quota.resetsAt).toLocaleString()}
>
{new Date(preferences.quota.resetsAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
@@ -2679,8 +2677,15 @@ const HomarrProviderQuota = () => {
const Composer = (props: ComposerProps) => {
const t = useScopedI18n("common.assistant");
const preferences = useAssistantPreferences();
const running = useAuiState((state) => state.thread.isRunning);
const hasPendingAction = props.pendingAction !== undefined;
const providerUnavailable = isAssistantProviderUnavailable({
provider: preferences.provider,
signedIn: preferences.providerUser !== null,
remaining: preferences.quota?.remaining,
});
const sendBlocked = hasPendingAction || providerUnavailable;
const composerInputRef = useRef<HTMLDivElement>(null);
const composerLabel = t("composerPlaceholder");
@@ -2701,7 +2706,7 @@ const Composer = (props: ComposerProps) => {
className={classes.composer}
data-pending-action={hasPendingAction || undefined}
onSubmit={(event) => {
if (hasPendingAction) event.preventDefault();
if (sendBlocked) event.preventDefault();
}}
>
<ComposerTriggers />
@@ -2747,8 +2752,14 @@ const Composer = (props: ComposerProps) => {
color="red"
variant="filled"
size="lg"
aria-label={hasPendingAction ? t("pendingAction.sendBlocked") : t("send")}
disabled={hasPendingAction}
aria-label={
hasPendingAction
? t("pendingAction.sendBlocked")
: providerUnavailable
? t("providerQuota.unavailableDescription")
: t("send")
}
disabled={sendBlocked}
>
<IconArrowUp size={18} />
</ActionIcon>
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getAssistantProviderQuotaLevel } from "./assistant-provider-quota";
import { getAssistantProviderQuotaLevel, isAssistantProviderUnavailable } from "./assistant-provider-quota";
describe("getAssistantProviderQuotaLevel", () => {
test.each([
@@ -12,4 +12,11 @@ describe("getAssistantProviderQuotaLevel", () => {
] as const)("maps %o to %s", (usage, expected) => {
expect(getAssistantProviderQuotaLevel(usage)).toBe(expected);
});
test("blocks only unavailable Homarr provider sessions", () => {
expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: false, remaining: undefined })).toBe(true);
expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: true, remaining: 0 })).toBe(true);
expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: true, remaining: undefined })).toBe(false);
expect(isAssistantProviderUnavailable({ provider: "openrouter", signedIn: false, remaining: 0 })).toBe(false);
});
});
@@ -1,4 +1,5 @@
import type { WorkshopAssistantUsage } from "@homarr/workshop/schema";
import type { AssistantProvider } from "@homarr/definitions";
export type AssistantProviderQuotaLevel = "ok" | "warning" | "bad" | "dead";
@@ -11,3 +12,13 @@ export const getAssistantProviderQuotaLevel = (
if (remainingRatio <= 0.5) return "warning";
return "ok";
};
export const isAssistantProviderUnavailable = ({
provider,
signedIn,
remaining,
}: {
provider: AssistantProvider | null;
signedIn: boolean;
remaining: number | undefined;
}) => provider === "homarr" && (!signedIn || remaining === 0);
@@ -29,6 +29,7 @@ import { createWorkshopClient } from "~/components/workshop/workshop-client";
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
import { isAssistantProviderUnavailable } from "./assistant-provider-quota";
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
import { getRunningAssistantPartType } from "./assistant-activity-state";
import { prepareAssistantRequestBody } from "./assistant-attachment-payload";
@@ -169,7 +170,7 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
reasoning: current.reasoning,
};
}, []);
const getRequestHeaders = useCallback(() => {
const getRequestHeaders = useCallback((): Record<string, string> => {
if (data?.provider !== "homarr") return {};
const token = workshopClient.authToken;
return token ? { [assistantHomarrProviderTokenHeader]: token } : {};
@@ -412,18 +413,19 @@ const AssistantThreadRuntime = () => {
const localThreadId = useAuiState((state) => state.threadListItem.id);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const preferences = useAssistantPreferences();
const { getRequestBody, getRequestHeaders } = preferences;
const transport = useMemo(
() =>
new AssistantChatTransport({
api: "/api/assistant/chat",
body: preferences.getRequestBody,
body: getRequestBody,
fetch: (input, init) => {
const headers = new Headers(init?.headers);
for (const [name, value] of Object.entries(preferences.getRequestHeaders())) headers.set(name, value);
for (const [name, value] of Object.entries(getRequestHeaders())) headers.set(name, value);
return globalThis.fetch(input, { ...init, headers, body: prepareAssistantRequestBody(init?.body) });
},
}),
[preferences.getRequestBody, preferences.getRequestHeaders],
[getRequestBody, getRequestHeaders],
);
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
const selectedModel = preferences.models.find((model) => model.id === preferences.modelId);
@@ -447,17 +449,14 @@ const AssistantThreadRuntime = () => {
}),
[t, threadId],
);
const onError = useCallback(
() => {
void preferences.refreshQuota();
showErrorNotification({
title: t("responseError.title"),
message: t("responseError.description"),
autoClose: 10_000,
});
},
[preferences, t],
);
const onError = useCallback(() => {
void preferences.refreshQuota();
showErrorNotification({
title: t("responseError.title"),
message: t("responseError.description"),
autoClose: 10_000,
});
}, [preferences, t]);
const chat = useChat<AssistantUIMessage>({
id: localThreadId,
@@ -650,6 +649,11 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
);
const pendingAction = getPendingAssistantAction(latestAssistantMessage);
const assistantIsRunning = isRunning || queuedPrompt !== null;
const providerUnavailable = isAssistantProviderUnavailable({
provider: preferences.provider,
signedIn: preferences.providerUser !== null,
remaining: preferences.quota?.remaining,
});
const notificationKey = getNotificationKey(latestAssistantMessage);
const notificationStateRef = useRef(initialAssistantNotificationState);
const mutationRefreshStateRef = useRef<{ conversationId: string | null; toolCallIds: Set<string> }>({
@@ -680,6 +684,15 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
(prompt: string) => {
const text = prompt.trim();
if (text.length === 0) return false;
if (providerUnavailable) {
showWarningNotification({
title: t("providerQuota.title"),
message: preferences.providerUser
? t("providerQuota.exhaustedDescription")
: t("providerQuota.signInDescription"),
});
return false;
}
if (assistantIsRunning || latestStatus?.type === "requires-action") {
showWarningNotification({
title: t("busy.title"),
@@ -694,7 +707,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
}
return sendPromptThroughRuntime(aui, text);
},
[assistantIsRunning, aui, isLoading, latestStatus?.type, t],
[assistantIsRunning, aui, isLoading, latestStatus?.type, preferences.providerUser, providerUnavailable, t],
);
const selectModel = useCallback(
(modelId: string) => {
@@ -712,10 +725,14 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
useEffect(() => {
if (isLoading || isRunning || queuedPrompt === null) return;
if (providerUnavailable) {
setQueuedPrompt(null);
return;
}
const prompt = queuedPrompt;
setQueuedPrompt(null);
sendPromptThroughRuntime(aui, prompt);
}, [aui, isLoading, isRunning, queuedPrompt]);
}, [aui, isLoading, isRunning, providerUnavailable, queuedPrompt]);
useEffect(() => {
if (isLoading) return;
@@ -757,12 +774,16 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]);
const spotlightItem = useMemo(() => {
const canSend = !assistantIsRunning && latestStatus?.type !== "requires-action";
const canSend = !providerUnavailable && !assistantIsRunning && latestStatus?.type !== "requires-action";
return {
id: "homarr-assistant",
name: t("spotlight"),
icon: "/logo/logo.png",
description: canSend ? t("spotlightDescription") : t("busy.description"),
description: providerUnavailable
? t("providerQuota.unavailableDescription")
: canSend
? t("spotlightDescription")
: t("busy.description"),
unavailable: !canSend,
alwaysVisible: true,
interaction: (query: string) =>
@@ -773,7 +794,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
canSend,
}),
};
}, [assistantIsRunning, latestStatus?.type, sendPrompt, t]);
}, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]);
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
const value = useMemo(