Complete Homarr provider integration
This commit is contained in:
1 parent
bc59355eab
commit
165201632d
23 files changed
+986
-61
No files matched your search
@@ -53,7 +53,10 @@ Homarr includes presets for OpenRouter, OpenAI, Anthropic Claude, Google Gemini,
|
||||
DeepSeek, Together AI, Ollama, and LM Studio. Provider icons make the presets easy to identify. A custom
|
||||
preset connects any other service that implements the OpenAI Chat Completions API.
|
||||
|
||||
The provider list also shows **Homarr — Coming soon** as a disabled option.
|
||||
The **Homarr provider** offers one community-funded model, DeepSeek V4 Flash Latest, without an administrator API key.
|
||||
Users authorize it with their existing Community Workshop GitHub session. A compact battery beside the model selector
|
||||
shows their remaining daily request allowance and the UTC reset time. See [Homarr provider](./homarr-provider) for its
|
||||
quota, privacy, live activity, and funding details.
|
||||
|
||||
The management page presents setup as three compact sections: provider and endpoint, credentials, then
|
||||
model and availability. A status summary at the top shows whether the connection is ready, whether a key
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Homarr provider
|
||||
sidebar_position: 7
|
||||
tags:
|
||||
- Assistant
|
||||
- AI
|
||||
- Privacy
|
||||
- Community
|
||||
---
|
||||
|
||||
import { AssistantProviderActivity } from "@site/src/components/assistant-provider/assistant-provider-activity";
|
||||
|
||||
# Homarr provider
|
||||
|
||||
The Homarr provider gives signed-in community members a small daily allowance for **DeepSeek V4 Flash Latest**.
|
||||
It is an OpenAI-compatible proxy operated alongside the [Community Workshop](./workshop). Homarr administrators can
|
||||
select it without adding an API key to their own instance. Each user signs in to the Workshop with GitHub, and that
|
||||
existing PocketBase session authorizes only their requests.
|
||||
|
||||
<AssistantProviderActivity />
|
||||
|
||||
## Allowance
|
||||
|
||||
- The default allowance is **50 requests per user per UTC day**.
|
||||
- Every model request consumes one request. Every completed Homarr tool call consumes one additional request.
|
||||
- The allowance resets at midnight UTC.
|
||||
- The battery beside the assistant model selector shows the remaining count. Select it to see the limit, reset time,
|
||||
Workshop identity, and current status.
|
||||
- A future account plan can change the stored per-user limit without changing other users.
|
||||
|
||||
## Privacy
|
||||
|
||||
The proxy streams the model response without storing prompts, messages, attachments, tool inputs, tool outputs, or
|
||||
response text. Private accounting records contain only the Workshop user ID, status, model identifier, request-unit
|
||||
count, token totals, duration, cost, and timestamps. The public live feed above is a separate read-only collection
|
||||
with the same operational metrics and **no user or GitHub field**.
|
||||
|
||||
The upstream OpenRouter key exists only in the Workshop server environment. It is never sent to Homarr instances or
|
||||
browsers. A user's Workshop token is forwarded by their Homarr server only to the configured Workshop origin.
|
||||
|
||||
## Fund free access
|
||||
|
||||
Inference is paid from the Homarr community budget. If this provider is useful, help keep the daily allowance
|
||||
available through the [Homarr OpenCollective](https://opencollective.com/homarr).
|
||||
|
||||
<a className="button button--primary" href="https://opencollective.com/homarr">
|
||||
Fund free Homarr AI
|
||||
</a>
|
||||
|
||||
## Share what you build
|
||||
|
||||
The provider is intended to help people create useful dashboards and Custom Widgets without first configuring a
|
||||
commercial model account. Publish successful Custom Widgets in the [Community Workshop](pathname:///workshop), and
|
||||
share the prompt, API source, and screenshots so other Homarr users can improve them.
|
||||
|
||||
## Run the proxy
|
||||
|
||||
The open-source proxy is part of the Workshop image. Operators configure it with server-only environment variables:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `OPENROUTER_API_KEY` | Upstream key used only by the Workshop proxy | Disabled when empty |
|
||||
| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Initial daily limit stored for each new user | `50` |
|
||||
| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible upstream base URL | `https://openrouter.ai/api/v1` |
|
||||
|
||||
Changing the global default affects new quota records. Existing per-user limits remain unchanged so administrators can
|
||||
support future account plans. Run `pnpm test:workshop` to verify authentication, streaming, UTC reset, atomic quota
|
||||
enforcement, tool-call accounting, private records, anonymous public activity, realtime delivery, and migration rollback.
|
||||
@@ -0,0 +1,161 @@
|
||||
.root {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
background: var(--ifm-background-surface-color);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-300);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.beep {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: #2f9e44;
|
||||
box-shadow: 0 0 0 0 rgb(47 158 68 / 35%);
|
||||
animation: live-beep 2.2s ease-out infinite;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
min-width: 36rem;
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.58rem 0.75rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.status[data-status="processing"] {
|
||||
color: #e67700;
|
||||
}
|
||||
|
||||
.status[data-status="completed"] {
|
||||
color: #2f9e44;
|
||||
}
|
||||
|
||||
.status[data-status="failed"] {
|
||||
color: #e03131;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 1.2rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.compact {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.compact .header {
|
||||
padding: 0.38rem 0.55rem;
|
||||
}
|
||||
|
||||
.compact .title {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.compact .live {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.compact .table {
|
||||
min-width: 0;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.compact .table th,
|
||||
.compact .table td {
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
.compact .table th:nth-child(n + 4),
|
||||
.compact .table td:nth-child(n + 4) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compact .empty {
|
||||
padding: 0.7rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@keyframes live-beep {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgb(47 158 68 / 35%);
|
||||
}
|
||||
|
||||
55%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0.35rem rgb(47 158 68 / 0%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.beep {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.root:not(.compact) .table th:nth-child(5),
|
||||
.root:not(.compact) .table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import { IconCheck, IconLoader2, IconX } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import type { WorkshopAssistantActivity } from "@homarr/workshop/schema";
|
||||
import { workshopAssistantActivitySchema } from "@homarr/workshop/schema";
|
||||
|
||||
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
|
||||
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
|
||||
|
||||
import styles from "./assistant-provider-activity.module.css";
|
||||
|
||||
const statusIcon = {
|
||||
processing: IconLoader2,
|
||||
completed: IconCheck,
|
||||
failed: IconX,
|
||||
};
|
||||
|
||||
const statusLabel = {
|
||||
processing: "Processing",
|
||||
completed: "Completed",
|
||||
failed: "Failed",
|
||||
};
|
||||
|
||||
const mergeActivity = (current: WorkshopAssistantActivity[], next: WorkshopAssistantActivity) =>
|
||||
[next, ...current.filter((item) => item.id !== next.id)]
|
||||
.toSorted((left, right) => Date.parse(right.created) - Date.parse(left.created))
|
||||
.slice(0, 10);
|
||||
|
||||
export const AssistantProviderActivity = ({ compact = false }: { compact?: boolean }) => {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
|
||||
const [activities, setActivities] = useState<WorkshopAssistantActivity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const workshopUrl = useMemo(() => getRuntimeWorkshopApiUrl(configuredWorkshopUrl), [configuredWorkshopUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
const backend = getWorkshopBackend(workshopUrl);
|
||||
const load = async () => {
|
||||
try {
|
||||
const initial = await backend.listAssistantActivity(10);
|
||||
if (active) setActivities(initial);
|
||||
unsubscribe = await backend.pocketBase.collection("assistant_activity").subscribe("*", (event) => {
|
||||
const parsed = workshopAssistantActivitySchema.safeParse(event.record);
|
||||
if (!active || !parsed.success) return;
|
||||
setConnected(true);
|
||||
setActivities((current) => mergeActivity(current, parsed.data));
|
||||
});
|
||||
if (active) setConnected(true);
|
||||
} catch {
|
||||
if (active) setConnected(false);
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [workshopUrl]);
|
||||
|
||||
const visibleActivities = compact ? activities.slice(0, 3) : activities;
|
||||
|
||||
return (
|
||||
<section className={clsx(styles.root, compact && styles.compact)} aria-label="Live Homarr provider activity">
|
||||
<header className={styles.header}>
|
||||
<h3 className={styles.title}>Homarr provider activity</h3>
|
||||
<span className={styles.live} aria-live="polite">
|
||||
<span className={styles.beep} aria-hidden />
|
||||
{connected ? "Live" : loading ? "Connecting" : "Offline"}
|
||||
</span>
|
||||
</header>
|
||||
{visibleActivities.length > 0 ? (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Status</th>
|
||||
<th>Requests</th>
|
||||
<th>Tokens</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleActivities.map((activity) => {
|
||||
const StatusIcon = statusIcon[activity.status];
|
||||
return (
|
||||
<tr key={activity.id}>
|
||||
<td>
|
||||
<time dateTime={activity.created} title={new Date(activity.created).toLocaleString()}>
|
||||
{new Date(activity.created).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</time>
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.status} data-status={activity.status}>
|
||||
<StatusIcon size={13} aria-hidden />
|
||||
{statusLabel[activity.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td>{activity.requestUnits.toLocaleString()}</td>
|
||||
<td>{activity.totalTokens > 0 ? activity.totalTokens.toLocaleString() : "–"}</td>
|
||||
<td>{activity.durationMs > 0 ? `${(activity.durationMs / 1000).toFixed(1)} s` : "–"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty}>{loading ? "Loading recent activity…" : "No provider requests yet today."}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppWidget } from "./widgets/app-widget";
|
||||
import { ClockWidget } from "./widgets/clock-widget";
|
||||
import { DownloadsWidget } from "./widgets/downloads-widget";
|
||||
import { AssistantProviderWidget } from "./widgets/assistant-provider-widget";
|
||||
import { WeatherWidget } from "./widgets/weather-widget";
|
||||
import { StockWidget } from "./widgets/stock-widget";
|
||||
import { EntityStateWidget } from "./widgets/entity-state-widget";
|
||||
@@ -12,7 +12,7 @@ export const HeroCards = () => {
|
||||
<AppWidget />
|
||||
<AppWidget className="hidden 3xl:block" />
|
||||
<WeatherWidget />
|
||||
<DownloadsWidget />
|
||||
<AssistantProviderWidget />
|
||||
<EntityStateWidget />
|
||||
<AppWidget />
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AssistantProviderActivity } from "@site/src/components/assistant-provider/assistant-provider-activity";
|
||||
|
||||
import type { CommonWidgetProps } from "./card";
|
||||
import { WidgetCard } from "./card";
|
||||
|
||||
export const AssistantProviderWidget = ({ className }: CommonWidgetProps) => (
|
||||
<WidgetCard width={2} className={className}>
|
||||
<AssistantProviderActivity compact />
|
||||
</WidgetCard>
|
||||
);
|
||||
+29
-33
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -231,7 +231,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 +260,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)
|
||||
@@ -553,16 +554,22 @@ export const assistantRouter = createTRPCRouter({
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const assistantProviderIds = [
|
||||
"homarr",
|
||||
"openrouter",
|
||||
"openai",
|
||||
"anthropic",
|
||||
@@ -15,6 +16,7 @@ export const assistantProviderIds = [
|
||||
|
||||
export type AssistantProvider = (typeof assistantProviderIds)[number];
|
||||
export type AssistantProviderCategory = "hosted" | "local" | "custom";
|
||||
export const assistantHomarrProviderTokenHeader = "X-Homarr-Provider-Token";
|
||||
|
||||
export const assistantReasoningModes = ["auto", "none", "minimal", "low", "medium", "high"] as const;
|
||||
export type AssistantReasoningMode = (typeof assistantReasoningModes)[number];
|
||||
@@ -43,6 +45,14 @@ interface AssistantProviderPreset {
|
||||
}
|
||||
|
||||
export const assistantProviderPresets = {
|
||||
homarr: {
|
||||
baseUrl: "https://homarr.dev/api/ai/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: false,
|
||||
category: "hosted",
|
||||
discoveryAuthentication: "bearer",
|
||||
iconUrl: "https://homarr.dev/img/logo.png",
|
||||
},
|
||||
openrouter: {
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
@@ -149,5 +159,5 @@ export const assistantProviderRequiresApiKey = (provider: AssistantProvider) =>
|
||||
* OpenRouter and Homarr Router implement OpenRouter server tools directly.
|
||||
* Custom endpoints may opt in when they proxy the OpenRouter request format.
|
||||
*/
|
||||
export const assistantProviderCanUseOpenRouterServerTools = (provider: AssistantProvider | "homarr") =>
|
||||
export const assistantProviderCanUseOpenRouterServerTools = (provider: AssistantProvider) =>
|
||||
provider === "openrouter" || provider === "homarr" || provider === "custom";
|
||||
@@ -2083,6 +2083,25 @@
|
||||
"high": "High"
|
||||
}
|
||||
},
|
||||
"providerQuota": {
|
||||
"title": "Homarr provider",
|
||||
"communityWorkshop": "Community Workshop",
|
||||
"dailyAllowance": "Daily request allowance",
|
||||
"remainingLabel": "{remaining} of {limit} Homarr provider requests remaining",
|
||||
"loading": "Loading Homarr provider allowance",
|
||||
"signInRequired": "Workshop sign-in required for the Homarr provider",
|
||||
"signInDescription": "Sign in with GitHub through the Community Workshop to receive your separate daily allowance.",
|
||||
"signIn": "Sign in with GitHub",
|
||||
"reset": "Resets {relative} at",
|
||||
"toolCalls": "Each model request and completed tool call uses one request.",
|
||||
"retry": "Reload allowance",
|
||||
"level": {
|
||||
"ok": "OK",
|
||||
"warning": "Warning",
|
||||
"bad": "Low",
|
||||
"dead": "Used"
|
||||
}
|
||||
},
|
||||
"stop": "Stop response",
|
||||
"send": "Send message",
|
||||
"copy": "Copy response",
|
||||
@@ -6176,7 +6195,8 @@
|
||||
"title": "Credentials",
|
||||
"description": "Add the secrets this endpoint needs. Stored values stay encrypted and cannot be read back.",
|
||||
"required": "Key required",
|
||||
"optional": "Key optional"
|
||||
"optional": "Key optional",
|
||||
"workshop": "Per-user GitHub sign-in"
|
||||
},
|
||||
"provider": {
|
||||
"title": "Provider",
|
||||
@@ -6187,7 +6207,8 @@
|
||||
},
|
||||
"options": {
|
||||
"homarr": {
|
||||
"label": "Homarr"
|
||||
"label": "Homarr",
|
||||
"description": "Community-funded DeepSeek V4 Flash access through Homarr. Each user signs in with the Community Workshop and receives a daily allowance."
|
||||
},
|
||||
"openrouter": {
|
||||
"label": "OpenRouter",
|
||||
@@ -6238,7 +6259,10 @@
|
||||
"description": "Any other endpoint compatible with OpenAI Chat Completions."
|
||||
}
|
||||
},
|
||||
"comingSoon": "Coming soon"
|
||||
"homarrAuth": {
|
||||
"title": "No administrator API key required",
|
||||
"description": "Homarr keeps the upstream OpenRouter credential on the Community Workshop server. Each user authorizes requests with their own Workshop GitHub session, so their daily allowance stays separate."
|
||||
}
|
||||
},
|
||||
"baseUrl": {
|
||||
"title": "API base URL",
|
||||
|
||||
@@ -13,6 +13,8 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
import type {
|
||||
WorkshopComment,
|
||||
WorkshopAssistantActivity,
|
||||
WorkshopAssistantUsage,
|
||||
WorkshopReport,
|
||||
WorkshopReportSummary,
|
||||
WorkshopSubmissionDetail,
|
||||
@@ -28,6 +30,8 @@ import {
|
||||
WORKSHOP_REQUEST_TIMEOUT_MS,
|
||||
WORKSHOP_SCHEMA_BY_TYPE,
|
||||
workshopCommentSchema,
|
||||
workshopAssistantActivitySchema,
|
||||
workshopAssistantUsageSchema,
|
||||
workshopReportSchema,
|
||||
workshopReportSummarySchema,
|
||||
workshopSubmissionDetailSchema,
|
||||
@@ -140,6 +144,19 @@ export interface WorkshopCommentRecord extends WorkshopBaseRecord {
|
||||
expand?: { author?: WorkshopUserRecord };
|
||||
}
|
||||
|
||||
export interface WorkshopAssistantActivityRecord extends WorkshopBaseRecord {
|
||||
status: "processing" | "completed" | "failed";
|
||||
model: string;
|
||||
requestUnits: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
durationMs: number;
|
||||
cost: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface TypedWorkshopPocketBase extends PocketBase {
|
||||
collection(idOrName: "users"): RecordService<WorkshopUserRecord>;
|
||||
collection(idOrName: "submissions"): RecordService<WorkshopSubmissionRecord>;
|
||||
@@ -148,6 +165,7 @@ export interface TypedWorkshopPocketBase extends PocketBase {
|
||||
collection(idOrName: "reports"): RecordService<WorkshopReportRecord>;
|
||||
collection(idOrName: "workshop_report_summaries"): RecordService<WorkshopReportSummaryRecord>;
|
||||
collection(idOrName: "comments"): RecordService<WorkshopCommentRecord>;
|
||||
collection(idOrName: "assistant_activity"): RecordService<WorkshopAssistantActivityRecord>;
|
||||
collection(idOrName: string): RecordService;
|
||||
}
|
||||
|
||||
@@ -280,6 +298,10 @@ export class WorkshopBackend {
|
||||
});
|
||||
}
|
||||
|
||||
public get authToken(): string | null {
|
||||
return this.pocketBase.authStore.isValid && this.currentUser ? this.pocketBase.authStore.token : null;
|
||||
}
|
||||
|
||||
public subscribeToAuth(listener: (user: WorkshopUser | null) => void) {
|
||||
return this.pocketBase.authStore.onChange(() => listener(this.currentUser), true);
|
||||
}
|
||||
@@ -314,6 +336,35 @@ export class WorkshopBackend {
|
||||
this.pocketBase.authStore.clear();
|
||||
}
|
||||
|
||||
public async getAssistantUsage(signal?: AbortSignal): Promise<WorkshopAssistantUsage> {
|
||||
try {
|
||||
const value = await this.pocketBase.send("/api/ai/usage", {
|
||||
method: "GET",
|
||||
signal: requestSignal(signal),
|
||||
requestKey: null,
|
||||
});
|
||||
return workshopAssistantUsageSchema.parse(value);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load the Homarr provider allowance");
|
||||
}
|
||||
}
|
||||
|
||||
public async listAssistantActivity(limit = 10, signal?: AbortSignal): Promise<WorkshopAssistantActivity[]> {
|
||||
try {
|
||||
const result = await this.pocketBase.collection("assistant_activity").getList(1, Math.min(Math.max(limit, 1), 50), {
|
||||
sort: "-created",
|
||||
signal: requestSignal(signal),
|
||||
requestKey: null,
|
||||
});
|
||||
return result.items.flatMap((item) => {
|
||||
const parsed = workshopAssistantActivitySchema.safeParse(item);
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
});
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load live Homarr provider activity");
|
||||
}
|
||||
}
|
||||
|
||||
public async list(options: WorkshopListOptions = {}): Promise<WorkshopPage<WorkshopSubmissionSummary>> {
|
||||
try {
|
||||
const page = options.page ?? 1;
|
||||
|
||||
@@ -6,16 +6,25 @@ const mocks = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getFullList: vi.fn(),
|
||||
getList: vi.fn(),
|
||||
send: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("pocketbase", () => {
|
||||
class MockPocketBase {
|
||||
public readonly authStore = {
|
||||
token: "",
|
||||
record: null as Record<string, unknown> | null,
|
||||
isValid: false,
|
||||
save: (_token: string, record: Record<string, unknown>) => {
|
||||
save: (token: string, record: Record<string, unknown>) => {
|
||||
this.authStore.token = token;
|
||||
this.authStore.record = record;
|
||||
this.authStore.isValid = true;
|
||||
},
|
||||
clear: () => {
|
||||
this.authStore.token = "";
|
||||
this.authStore.record = null;
|
||||
this.authStore.isValid = false;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,6 +37,10 @@ vi.mock("pocketbase", () => {
|
||||
return this;
|
||||
}
|
||||
|
||||
public send(path: string, options: unknown) {
|
||||
return mocks.send(path, options);
|
||||
}
|
||||
|
||||
public filter(template: string, values: Record<string, string>) {
|
||||
return template.replaceAll(/\{:(\w+)\}/gu, (_match, key: string) => `'${values[key]}'`);
|
||||
}
|
||||
@@ -62,6 +75,7 @@ describe("WorkshopBackend", () => {
|
||||
mocks.create.mockReset();
|
||||
mocks.getFullList.mockReset();
|
||||
mocks.getList.mockReset();
|
||||
mocks.send.mockReset();
|
||||
mocks.update.mockReset();
|
||||
});
|
||||
|
||||
@@ -91,6 +105,54 @@ describe("WorkshopBackend", () => {
|
||||
expect(mocks.autoCancellation).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("reuses a valid Workshop session for the Homarr provider allowance", async () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
expect(client.authToken).toBeNull();
|
||||
client.pocketBase.authStore.save("workshop-token", { id: "author-id", name: "octocat" } as never);
|
||||
mocks.send.mockResolvedValue({
|
||||
limit: 50,
|
||||
used: 7,
|
||||
remaining: 43,
|
||||
resetsAt: "2026-08-12T00:00:00Z",
|
||||
});
|
||||
|
||||
await expect(client.getAssistantUsage()).resolves.toMatchObject({ limit: 50, remaining: 43 });
|
||||
expect(client.authToken).toBe("workshop-token");
|
||||
expect(mocks.send).toHaveBeenCalledWith(
|
||||
"/api/ai/usage",
|
||||
expect.objectContaining({ method: "GET", requestKey: null, signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
test("loads only valid anonymous provider activity records", async () => {
|
||||
mocks.getList.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "activity-id",
|
||||
status: "completed",
|
||||
model: "homarr/deepseek-v4-flash-latest",
|
||||
requestUnits: 2,
|
||||
inputTokens: 12,
|
||||
outputTokens: 7,
|
||||
totalTokens: 19,
|
||||
durationMs: 250,
|
||||
cost: 0.0001,
|
||||
created: "2026-08-11 10:00:00.000Z",
|
||||
updated: "2026-08-11 10:00:00.250Z",
|
||||
},
|
||||
{ id: "invalid" },
|
||||
],
|
||||
});
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
|
||||
await expect(client.listAssistantActivity()).resolves.toHaveLength(1);
|
||||
expect(mocks.getList).toHaveBeenCalledWith(
|
||||
1,
|
||||
10,
|
||||
expect.objectContaining({ sort: "-created", requestKey: null, signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
test("sends the exported schema while leaving initial revision ownership to PocketBase", async () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
client.pocketBase.authStore.save("token", { id: "author-id" } as never);
|
||||
|
||||
@@ -102,6 +102,29 @@ export const workshopUserSchema = z.object({
|
||||
});
|
||||
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
|
||||
|
||||
export const workshopAssistantUsageSchema = z.object({
|
||||
limit: z.number().int().nonnegative(),
|
||||
used: z.number().int().nonnegative(),
|
||||
remaining: z.number().int().nonnegative(),
|
||||
resetsAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
export type WorkshopAssistantUsage = z.infer<typeof workshopAssistantUsageSchema>;
|
||||
|
||||
export const workshopAssistantActivitySchema = z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(["processing", "completed", "failed"]),
|
||||
model: z.string(),
|
||||
requestUnits: z.number().int().positive(),
|
||||
inputTokens: z.number().int().nonnegative().default(0),
|
||||
outputTokens: z.number().int().nonnegative().default(0),
|
||||
totalTokens: z.number().int().nonnegative().default(0),
|
||||
durationMs: z.number().int().nonnegative().default(0),
|
||||
cost: z.number().nonnegative().default(0),
|
||||
created: z.string(),
|
||||
updated: z.string(),
|
||||
});
|
||||
export type WorkshopAssistantActivity = z.infer<typeof workshopAssistantActivitySchema>;
|
||||
|
||||
const normalizeWorkshopSubmissionRecord = (value: unknown) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
||||
const record = value as Record<string, unknown>;
|
||||
|
||||
Reference in new issue
Block a user