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

+4 -1
View File
@@ -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>
);