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>
|
||||
);
|
||||
Reference in new issue
Block a user