Complete Homarr provider integration

This commit is contained in:
ajnart committed 2026-08-11 19:19:17 +02:00
1 parent bc59355eab
commit 165201632d
23 files changed
+986 -61

No files matched your search

@@ -26,6 +26,7 @@ import {
} from "@mantine/core";
import {
IconAlertTriangle,
IconBrandGithub,
IconCheck,
IconCircleCheck,
IconDatabaseSearch,
@@ -56,9 +57,7 @@ import { formatAssistantContextWindow, getAssistantConnectionState } from "./ass
type HeaderEntry = { id: number; name: string; value: string };
type CredentialFlow = "idle" | "remove";
type AssistantProviderOption = AssistantProvider | "homarr";
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProviderOption; size?: number }) => {
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProvider; size?: number }) => {
if (providerId === "homarr") {
return <Image src="/logo/logo.png" alt="" aria-hidden w={size} h={size} fit="contain" />;
}
@@ -203,20 +202,7 @@ export const AssistantConfiguration = () => {
label: t(`provider.options.${providerId}.label`),
}));
return {
group: t(`provider.groups.${category}`),
items:
category === "hosted"
? [
{
value: "homarr",
label: t("provider.options.homarr.label"),
disabled: true,
},
...items,
]
: items,
};
return { group: t(`provider.groups.${category}`), items };
}),
[t],
);
@@ -232,6 +218,7 @@ export const AssistantConfiguration = () => {
}, [modelId, models]);
const preset = assistantProviderPresets[provider];
const isHomarrProvider = provider === "homarr";
const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0);
const { hasStoredApiKey, connectionPending, connectionReady } = getAssistantConnectionState({
connectionConfigured: configuration?.connectionConfigured === true,
@@ -335,15 +322,19 @@ export const AssistantConfiguration = () => {
const saveConnection = (clearApiKey = false) => {
const customHeaders =
headers.length > 0 ? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value])) : undefined;
isHomarrProvider
? undefined
: headers.length > 0
? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value]))
: undefined;
updateConnection.mutate({
provider,
baseUrl,
modelDiscoveryPath: modelDiscoveryPath.trim() || null,
apiKey: clearApiKey ? undefined : apiKey.trim() || undefined,
clearApiKey,
apiKey: clearApiKey || isHomarrProvider ? undefined : apiKey.trim() || undefined,
clearApiKey: clearApiKey || isHomarrProvider,
customHeaders,
clearCustomHeaders: clearHeaders,
clearCustomHeaders: clearHeaders || isHomarrProvider,
});
};
@@ -434,17 +425,12 @@ export const AssistantConfiguration = () => {
leftSection={<ProviderIcon providerId={provider} />}
renderOption={({ option }) => (
<Group gap="sm" wrap="nowrap" align="center" w="100%">
<ProviderIcon providerId={option.value as AssistantProviderOption} />
<ProviderIcon providerId={option.value as AssistantProvider} />
<Box flex={1} miw={0}>
<Group gap="xs" wrap="nowrap" justify="space-between">
<Text size="sm" truncate>
{option.label}
</Text>
{option.value === "homarr" && (
<Badge size="xs" variant="light" color="gray" flex="0 0 auto">
{t("provider.comingSoon")}
</Badge>
)}
</Group>
</Box>
</Group>
@@ -473,7 +459,11 @@ export const AssistantConfiguration = () => {
title={t("credentials.title")}
description={t("credentials.description")}
status={
hasStoredApiKey ? undefined : (
isHomarrProvider ? (
<Badge variant="light" color="blue">
{t("credentials.workshop")}
</Badge>
) : hasStoredApiKey ? undefined : (
<Badge variant="light" color={preset.requiresApiKey ? "yellow" : "gray"}>
{preset.requiresApiKey ? t("credentials.required") : t("credentials.optional")}
</Badge>
@@ -494,7 +484,11 @@ export const AssistantConfiguration = () => {
</Group>
}
>
{hasStoredApiKey && credentialFlow === "idle" ? (
{isHomarrProvider ? (
<Alert color="blue" icon={<IconBrandGithub size={18} />} title={t("provider.homarrAuth.title")}>
{t("provider.homarrAuth.description")}
</Alert>
) : hasStoredApiKey && credentialFlow === "idle" ? (
<Group className={classes.credentialSummary} justify="space-between" align="center">
<Group className={classes.credentialSummaryContent} gap="sm" wrap="nowrap">
<ThemeIcon color="teal" variant="light" radius="xl">
@@ -545,8 +539,9 @@ export const AssistantConfiguration = () => {
/>
)}
<Accordion variant="contained" radius="md">
<Accordion.Item value="headers">
{!isHomarrProvider && (
<Accordion variant="contained" radius="md">
<Accordion.Item value="headers">
<Accordion.Control className={classes.advancedControl} icon={<IconShieldLock size={18} />}>
<Group justify="space-between" wrap="nowrap">
<Box>
@@ -617,8 +612,9 @@ export const AssistantConfiguration = () => {
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Accordion.Item>
</Accordion>
)}
</ConfigurationSection>
<ConfigurationSection
@@ -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", () => {
@@ -15,4 +21,36 @@ describe("toProviderOptionsKey", () => {
test("leaves names without separators unchanged", () => {
expect(toProviderOptionsKey("homarr")).toBe("homarr");
});
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("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();
});
});
@@ -15,3 +15,26 @@ 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;
};
import { assistantHomarrProviderTokenHeader } from "@homarr/definitions";
@@ -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";
@@ -424,6 +424,21 @@ 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 {
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();
@@ -565,7 +580,12 @@ export async function POST(request: Request) {
: {}),
...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,
@@ -2,6 +2,9 @@
import { createContext, useContext } from "react";
import type { AssistantProvider } from "@homarr/definitions";
import type { WorkshopAssistantUsage, WorkshopUser } from "@homarr/workshop/schema";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
/**
@@ -27,14 +30,22 @@ export interface AssistantContextValue {
export const AssistantContext = createContext<AssistantContextValue | null>(null);
export interface AssistantPreferencesContextValue {
provider: AssistantProvider | null;
defaultModelId: string | null;
modelId: string | null;
models: AssistantRuntimeModelOption[];
reasoning: AssistantReasoningMode;
isLoading: boolean;
providerUser: WorkshopUser | null;
quota: WorkshopAssistantUsage | null;
quotaLoading: boolean;
quotaError: string | null;
setModelId: (modelId: string) => void;
setReasoning: (reasoning: AssistantReasoningMode) => void;
getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode };
getRequestHeaders: () => Record<string, string>;
refreshQuota: () => Promise<void>;
signInToProvider: () => Promise<void>;
}
export const AssistantPreferencesContext = createContext<AssistantPreferencesContextValue | null>(null);
@@ -1210,6 +1210,43 @@
outline-offset: 2px;
}
.providerQuotaTrigger {
display: inline-flex;
height: 1.75rem;
flex: 0 0 auto;
align-items: center;
gap: 0.25rem;
padding-inline: 0.38rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.providerQuotaTrigger:hover {
background: var(--mantine-color-default-hover);
}
.providerQuotaTrigger:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.providerQuotaTrigger[data-level="ok"] {
color: var(--mantine-color-green-7);
}
.providerQuotaTrigger[data-level="warning"] {
color: light-dark(var(--mantine-color-yellow-8), var(--mantine-color-yellow-4));
}
.providerQuotaTrigger[data-level="bad"] {
color: light-dark(var(--mantine-color-orange-8), var(--mantine-color-orange-4));
}
.providerQuotaTrigger[data-level="dead"] {
color: var(--mantine-color-red-7);
}
.conversationContextPopover {
max-height: min(34rem, calc(100dvh - 1rem));
overflow: hidden;
@@ -60,6 +60,7 @@ import {
Image,
Loader,
Popover,
Progress,
RingProgress,
ScrollArea,
SegmentedControl,
@@ -72,6 +73,7 @@ import {
useComputedColorScheme,
} from "@mantine/core";
import { useReducedMotion, useWindowEvent } from "@mantine/hooks";
import { BatteryFull, BatteryLow, BatteryMedium, BatteryWarning } from "lucide-react";
import {
IconActivityHeartbeat,
IconAlertTriangle,
@@ -115,6 +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 { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -129,6 +132,7 @@ import {
getAssistantConversationExportFilename,
} from "./assistant-conversation-export";
import { AssistantDotMatrix } from "./assistant-dot-matrix";
import { useAssistantPreferences } from "./assistant-context";
import { AssistantImage } from "./assistant-image";
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
@@ -149,6 +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 { getAssistantToolTraceTarget } from "./assistant-tool-trace";
import { getSafeAssistantHttpUrl } from "./assistant-url";
@@ -2537,6 +2542,141 @@ const RuntimeControls = ({
);
};
const providerQuotaIcons = {
ok: BatteryFull,
warning: BatteryMedium,
bad: BatteryLow,
dead: BatteryWarning,
};
const providerQuotaColors = {
ok: "green",
warning: "yellow",
bad: "orange",
dead: "red",
} as const;
const HomarrProviderQuota = () => {
const t = useScopedI18n("common.assistant");
const preferences = useAssistantPreferences();
const resetAt = useMemo(() => new Date(preferences.quota?.resetsAt ?? Date.now()), [preferences.quota?.resetsAt]);
const resetRelative = useTimeAgo(resetAt, 30_000);
if (preferences.provider !== "homarr") return null;
const level = preferences.quota ? getAssistantProviderQuotaLevel(preferences.quota) : "dead";
const Icon = providerQuotaIcons[level];
const color = providerQuotaColors[level];
const percentage = preferences.quota
? Math.min(100, Math.max(0, (preferences.quota.remaining / Math.max(preferences.quota.limit, 1)) * 100))
: 0;
const label = preferences.providerUser
? preferences.quota
? t("providerQuota.remainingLabel", {
remaining: preferences.quota.remaining,
limit: preferences.quota.limit,
})
: t("providerQuota.loading")
: t("providerQuota.signInRequired");
return (
<Popover position="top" width={290} shadow="md" withinPortal>
<Popover.Target>
<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 ?? "–"}
</Text>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon color={color} variant="light" size="sm" radius="xl">
<Icon size={15} aria-hidden />
</ThemeIcon>
<Box>
<Text size="sm" fw={700}>
{t("providerQuota.title")}
</Text>
<Text size="xs" c="dimmed">
{preferences.providerUser?.name || t("providerQuota.communityWorkshop")}
</Text>
</Box>
</Group>
{preferences.quota && (
<Badge color={color} variant="light" size="sm">
{t(`providerQuota.level.${level}`)}
</Badge>
)}
</Group>
{!preferences.providerUser ? (
<>
<Text size="sm" c="dimmed">
{t("providerQuota.signInDescription")}
</Text>
<Button
size="compact-sm"
loading={preferences.quotaLoading}
onClick={() => void preferences.signInToProvider().catch(() => undefined)}
>
{t("providerQuota.signIn")}
</Button>
</>
) : preferences.quota ? (
<>
<Box>
<Group justify="space-between" gap="xs" mb={5}>
<Text size="xs" c="dimmed">
{t("providerQuota.dailyAllowance")}
</Text>
<Text size="xs" fw={650}>
{preferences.quota.remaining} / {preferences.quota.limit}
</Text>
</Group>
<Progress value={percentage} color={color} size="sm" aria-label={label} />
</Box>
<Text size="xs" c="dimmed">
{t("providerQuota.reset", { relative: resetRelative })}{" "}
<time dateTime={preferences.quota.resetsAt} title={new Date(preferences.quota.resetsAt).toLocaleString()}>
{new Date(preferences.quota.resetsAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
})}
</time>
</Text>
<Text size="xs" c="dimmed">
{t("providerQuota.toolCalls")}
</Text>
</>
) : (
<Button
variant="light"
size="compact-sm"
loading={preferences.quotaLoading}
onClick={() => void preferences.refreshQuota()}
>
{t("providerQuota.retry")}
</Button>
)}
{preferences.quotaError && (
<Text size="xs" c="red">
{preferences.quotaError}
</Text>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
};
const Composer = (props: ComposerProps) => {
const t = useScopedI18n("common.assistant");
const running = useAuiState((state) => state.thread.isRunning);
@@ -2618,6 +2758,7 @@ const Composer = (props: ComposerProps) => {
<Group className={classes.composerFooter} justify="space-between" gap="xs" wrap="nowrap">
<Group className={classes.composerControls} gap={5} wrap="nowrap">
<RuntimeControls {...props} />
<HomarrProviderQuota />
<ConversationContext />
</Group>
<Group className={classes.composerHints} gap="xs" wrap="nowrap">
@@ -0,0 +1,15 @@
import { describe, expect, test } from "vitest";
import { getAssistantProviderQuotaLevel } from "./assistant-provider-quota";
describe("getAssistantProviderQuotaLevel", () => {
test.each([
[{ limit: 50, remaining: 50 }, "ok"],
[{ limit: 50, remaining: 26 }, "ok"],
[{ limit: 50, remaining: 25 }, "warning"],
[{ limit: 50, remaining: 10 }, "bad"],
[{ limit: 50, remaining: 0 }, "dead"],
] as const)("maps %o to %s", (usage, expected) => {
expect(getAssistantProviderQuotaLevel(usage)).toBe(expected);
});
});
@@ -0,0 +1,13 @@
import type { WorkshopAssistantUsage } from "@homarr/workshop/schema";
export type AssistantProviderQuotaLevel = "ok" | "warning" | "bad" | "dead";
export const getAssistantProviderQuotaLevel = (
usage: Pick<WorkshopAssistantUsage, "limit" | "remaining">,
): AssistantProviderQuotaLevel => {
if (usage.remaining === 0 || usage.limit === 0) return "dead";
const remainingRatio = usage.remaining / usage.limit;
if (remainingRatio <= 0.2) return "bad";
if (remainingRatio <= 0.5) return "warning";
return "ok";
};
@@ -19,12 +19,13 @@ import { createAssistantStream } from "assistant-stream";
import { clientApi, fetchApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
import { assistantHomarrProviderTokenHeader, hotkeys } from "@homarr/definitions";
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
import { closeSpotlight } from "@homarr/spotlight/store";
import { useScopedI18n } from "@homarr/translation/client";
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
import { createWorkshopClient } from "~/components/workshop/workshop-client";
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
@@ -129,6 +130,11 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
});
const [modelId, setModelIdState] = useState<string | null>(null);
const [reasoning, setReasoningState] = useState<AssistantReasoningMode>("auto");
const workshopClient = useMemo(createWorkshopClient, []);
const [providerUser, setProviderUser] = useState(workshopClient.currentUser);
const [quota, setQuota] = useState<Awaited<ReturnType<typeof workshopClient.getAssistantUsage>> | null>(null);
const [quotaLoading, setQuotaLoading] = useState(false);
const [quotaError, setQuotaError] = useState<string | null>(null);
const previousDefaultModelIdRef = useRef<string | null | undefined>(undefined);
const preferencesRef = useRef<{ modelId: string | null; reasoning: AssistantReasoningMode }>({
modelId: null,
@@ -163,6 +169,41 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
reasoning: current.reasoning,
};
}, []);
const getRequestHeaders = useCallback(() => {
if (data?.provider !== "homarr") return {};
const token = workshopClient.authToken;
return token ? { [assistantHomarrProviderTokenHeader]: token } : {};
}, [data?.provider, workshopClient]);
const refreshQuota = useCallback(async () => {
if (data?.provider !== "homarr" || !workshopClient.authToken) {
setQuota(null);
setQuotaError(null);
return;
}
setQuotaLoading(true);
setQuotaError(null);
try {
setQuota(await workshopClient.getAssistantUsage());
} catch (error) {
setQuotaError(error instanceof Error ? error.message : "The Homarr provider allowance could not be loaded.");
} finally {
setQuotaLoading(false);
}
}, [data?.provider, workshopClient]);
const signInToProvider = useCallback(async () => {
setQuotaLoading(true);
setQuotaError(null);
try {
const user = await workshopClient.signInWithGitHub();
setProviderUser(user);
await refreshQuota();
} catch (error) {
setQuotaError(error instanceof Error ? error.message : "GitHub sign-in failed.");
throw error;
} finally {
setQuotaLoading(false);
}
}, [refreshQuota, workshopClient]);
useEffect(() => {
if (!data) return;
@@ -178,18 +219,60 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
setModelIdState(nextModelId);
}, [data]);
useEffect(() => {
if (data?.provider !== "homarr") {
setProviderUser(null);
setQuota(null);
setQuotaError(null);
return;
}
const unsubscribe = workshopClient.subscribeToAuth(setProviderUser);
void workshopClient.refreshAuth().then(setProviderUser);
return unsubscribe;
}, [data?.provider, workshopClient]);
useEffect(() => {
if (data?.provider !== "homarr" || !providerUser) return;
void refreshQuota();
}, [data?.provider, providerUser, refreshQuota]);
const value = useMemo(
() => ({
provider: data?.provider ?? null,
defaultModelId: data?.defaultModelId ?? null,
modelId,
models,
reasoning,
isLoading,
providerUser,
quota,
quotaLoading,
quotaError,
setModelId,
setReasoning,
getRequestBody,
getRequestHeaders,
refreshQuota,
signInToProvider,
}),
[data?.defaultModelId, getRequestBody, isLoading, modelId, models, reasoning, setModelId, setReasoning],
[
data?.defaultModelId,
data?.provider,
getRequestBody,
getRequestHeaders,
isLoading,
modelId,
models,
providerUser,
quota,
quotaError,
quotaLoading,
reasoning,
refreshQuota,
setModelId,
setReasoning,
signInToProvider,
],
);
return <AssistantPreferencesContext.Provider value={value}>{children}</AssistantPreferencesContext.Provider>;
@@ -334,9 +417,13 @@ const AssistantThreadRuntime = () => {
new AssistantChatTransport({
api: "/api/assistant/chat",
body: preferences.getRequestBody,
fetch: (input, init) => globalThis.fetch(input, { ...init, body: prepareAssistantRequestBody(init?.body) }),
fetch: (input, init) => {
const headers = new Headers(init?.headers);
for (const [name, value] of Object.entries(preferences.getRequestHeaders())) headers.set(name, value);
return globalThis.fetch(input, { ...init, headers, body: prepareAssistantRequestBody(init?.body) });
},
}),
[preferences.getRequestBody],
[preferences.getRequestBody, preferences.getRequestHeaders],
);
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
const selectedModel = preferences.models.find((model) => model.id === preferences.modelId);
@@ -361,19 +448,22 @@ const AssistantThreadRuntime = () => {
[t, threadId],
);
const onError = useCallback(
() =>
() => {
void preferences.refreshQuota();
showErrorNotification({
title: t("responseError.title"),
message: t("responseError.description"),
autoClose: 10_000,
}),
[t],
});
},
[preferences, t],
);
const chat = useChat<AssistantUIMessage>({
id: localThreadId,
transport,
onError,
onFinish: () => void preferences.refreshQuota(),
sendAutomaticallyWhen: shouldAutomaticallyContinueAssistant,
});