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>
);
@@ -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,
});
+16 -9
View File
@@ -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",
);
}
});
+11 -1
View File
@@ -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";
+27 -3
View File
@@ -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",
+51
View File
@@ -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;
+63 -1
View File
@@ -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);
+23
View File
@@ -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>;