diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index a3f8015a8..0084ef75d 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -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 diff --git a/apps/docs/docs/management/homarr-provider.mdx b/apps/docs/docs/management/homarr-provider.mdx new file mode 100644 index 000000000..8fbf8791e --- /dev/null +++ b/apps/docs/docs/management/homarr-provider.mdx @@ -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. + + + +## 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). + + + Fund free Homarr AI + + +## 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. diff --git a/apps/docs/src/components/assistant-provider/assistant-provider-activity.module.css b/apps/docs/src/components/assistant-provider/assistant-provider-activity.module.css new file mode 100644 index 000000000..b94ef90a4 --- /dev/null +++ b/apps/docs/src/components/assistant-provider/assistant-provider-activity.module.css @@ -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; + } +} diff --git a/apps/docs/src/components/assistant-provider/assistant-provider-activity.tsx b/apps/docs/src/components/assistant-provider/assistant-provider-activity.tsx new file mode 100644 index 000000000..3f01e4805 --- /dev/null +++ b/apps/docs/src/components/assistant-provider/assistant-provider-activity.tsx @@ -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([]); + 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 ( +
+
+

Homarr provider activity

+ + + {connected ? "Live" : loading ? "Connecting" : "Offline"} + +
+ {visibleActivities.length > 0 ? ( +
+ + + + + + + + + + + + {visibleActivities.map((activity) => { + const StatusIcon = statusIcon[activity.status]; + return ( + + + + + + + + ); + })} + +
TimeStatusRequestsTokensDuration
+ + + + + {statusLabel[activity.status]} + + {activity.requestUnits.toLocaleString()}{activity.totalTokens > 0 ? activity.totalTokens.toLocaleString() : "–"}{activity.durationMs > 0 ? `${(activity.durationMs / 1000).toFixed(1)} s` : "–"}
+
+ ) : ( +
{loading ? "Loading recent activity…" : "No provider requests yet today."}
+ )} +
+ ); +}; diff --git a/apps/docs/src/components/pages/home/hero/hero-cards.tsx b/apps/docs/src/components/pages/home/hero/hero-cards.tsx index a0c8df1d6..f370d2312 100644 --- a/apps/docs/src/components/pages/home/hero/hero-cards.tsx +++ b/apps/docs/src/components/pages/home/hero/hero-cards.tsx @@ -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 = () => { - + diff --git a/apps/docs/src/components/pages/home/hero/widgets/assistant-provider-widget.tsx b/apps/docs/src/components/pages/home/hero/widgets/assistant-provider-widget.tsx new file mode 100644 index 000000000..6c7a433a2 --- /dev/null +++ b/apps/docs/src/components/pages/home/hero/widgets/assistant-provider-widget.tsx @@ -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) => ( + + + +); diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx index cbda5efe0..e675d57ec 100644 --- a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx @@ -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 ; } @@ -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={} renderOption={({ option }) => ( - + {option.label} - {option.value === "homarr" && ( - - {t("provider.comingSoon")} - - )} @@ -473,7 +459,11 @@ export const AssistantConfiguration = () => { title={t("credentials.title")} description={t("credentials.description")} status={ - hasStoredApiKey ? undefined : ( + isHomarrProvider ? ( + + {t("credentials.workshop")} + + ) : hasStoredApiKey ? undefined : ( {preset.requiresApiKey ? t("credentials.required") : t("credentials.optional")} @@ -494,7 +484,11 @@ export const AssistantConfiguration = () => { } > - {hasStoredApiKey && credentialFlow === "idle" ? ( + {isHomarrProvider ? ( + } title={t("provider.homarrAuth.title")}> + {t("provider.homarrAuth.description")} + + ) : hasStoredApiKey && credentialFlow === "idle" ? ( @@ -545,8 +539,9 @@ export const AssistantConfiguration = () => { /> )} - - + {!isHomarrProvider && ( + + }> @@ -617,8 +612,9 @@ export const AssistantConfiguration = () => { )} - - + + + )} { 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(); + }); }); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-provider-options.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-provider-options.ts index 04f6d2f6b..8af821497 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-provider-options.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-provider-options.ts @@ -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"; diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index 1a29a31bf..7547be6bd 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -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, diff --git a/apps/nextjs/src/components/assistant/assistant-context.tsx b/apps/nextjs/src/components/assistant/assistant-context.tsx index 6c1306210..b5d2f0a51 100644 --- a/apps/nextjs/src/components/assistant/assistant-context.tsx +++ b/apps/nextjs/src/components/assistant/assistant-context.tsx @@ -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(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; + refreshQuota: () => Promise; + signInToProvider: () => Promise; } export const AssistantPreferencesContext = createContext(null); diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index abda0bf52..109c58ca9 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -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; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index f2c572eee..b1bdf7300 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -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 ( + + + + {preferences.quotaLoading && !preferences.quota ? : } + + {preferences.quota?.remaining ?? "–"} + + + + + + + + + + + + + {t("providerQuota.title")} + + + {preferences.providerUser?.name || t("providerQuota.communityWorkshop")} + + + + {preferences.quota && ( + + {t(`providerQuota.level.${level}`)} + + )} + + + {!preferences.providerUser ? ( + <> + + {t("providerQuota.signInDescription")} + + + + ) : preferences.quota ? ( + <> + + + + {t("providerQuota.dailyAllowance")} + + + {preferences.quota.remaining} / {preferences.quota.limit} + + + + + + {t("providerQuota.reset", { relative: resetRelative })}{" "} + + + + {t("providerQuota.toolCalls")} + + + ) : ( + + )} + {preferences.quotaError && ( + + {preferences.quotaError} + + )} + + + + ); +}; + const Composer = (props: ComposerProps) => { const t = useScopedI18n("common.assistant"); const running = useAuiState((state) => state.thread.isRunning); @@ -2618,6 +2758,7 @@ const Composer = (props: ComposerProps) => { + diff --git a/apps/nextjs/src/components/assistant/assistant-provider-quota.spec.ts b/apps/nextjs/src/components/assistant/assistant-provider-quota.spec.ts new file mode 100644 index 000000000..980089e87 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-provider-quota.spec.ts @@ -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); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-provider-quota.ts b/apps/nextjs/src/components/assistant/assistant-provider-quota.ts new file mode 100644 index 000000000..3bb39b5e6 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-provider-quota.ts @@ -0,0 +1,13 @@ +import type { WorkshopAssistantUsage } from "@homarr/workshop/schema"; + +export type AssistantProviderQuotaLevel = "ok" | "warning" | "bad" | "dead"; + +export const getAssistantProviderQuotaLevel = ( + usage: Pick, +): 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"; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index feb4d5933..792c43523 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -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(null); const [reasoning, setReasoningState] = useState("auto"); + const workshopClient = useMemo(createWorkshopClient, []); + const [providerUser, setProviderUser] = useState(workshopClient.currentUser); + const [quota, setQuota] = useState> | null>(null); + const [quotaLoading, setQuotaLoading] = useState(false); + const [quotaError, setQuotaError] = useState(null); const previousDefaultModelIdRef = useRef(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 {children}; @@ -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({ id: localThreadId, transport, onError, + onFinish: () => void preferences.refreshQuota(), sendAutomaticallyWhen: shouldAutomaticallyContinueAssistant, }); diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 209f544df..0c3262a81 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -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 diff --git a/packages/api/src/test/assistant-providers.spec.ts b/packages/api/src/test/assistant-providers.spec.ts index 751a2f5db..9fb268113 100644 --- a/packages/api/src/test/assistant-providers.spec.ts +++ b/packages/api/src/test/assistant-providers.spec.ts @@ -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", + ); } }); diff --git a/packages/definitions/src/assistant.ts b/packages/definitions/src/assistant.ts index 0220711a8..8355925bf 100644 --- a/packages/definitions/src/assistant.ts +++ b/packages/definitions/src/assistant.ts @@ -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"; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 5c49cdee1..da55fd47d 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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", diff --git a/packages/workshop/src/backend.ts b/packages/workshop/src/backend.ts index 33b2605e7..aed259e70 100644 --- a/packages/workshop/src/backend.ts +++ b/packages/workshop/src/backend.ts @@ -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; collection(idOrName: "submissions"): RecordService; @@ -148,6 +165,7 @@ export interface TypedWorkshopPocketBase extends PocketBase { collection(idOrName: "reports"): RecordService; collection(idOrName: "workshop_report_summaries"): RecordService; collection(idOrName: "comments"): RecordService; + collection(idOrName: "assistant_activity"): RecordService; 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 { + 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 { + 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> { try { const page = options.page ?? 1; diff --git a/packages/workshop/src/client.spec.ts b/packages/workshop/src/client.spec.ts index 26b6a2bd9..01a8a6ae0 100644 --- a/packages/workshop/src/client.spec.ts +++ b/packages/workshop/src/client.spec.ts @@ -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 | null, isValid: false, - save: (_token: string, record: Record) => { + save: (token: string, record: Record) => { + 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) { 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); diff --git a/packages/workshop/src/schema.ts b/packages/workshop/src/schema.ts index 8fe41386b..783e574c6 100644 --- a/packages/workshop/src/schema.ts +++ b/packages/workshop/src/schema.ts @@ -102,6 +102,29 @@ export const workshopUserSchema = z.object({ }); export type WorkshopUser = z.infer; +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; + +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; + const normalizeWorkshopSubmissionRecord = (value: unknown) => { if (typeof value !== "object" || value === null || Array.isArray(value)) return value; const record = value as Record;