fix: harden the Homarr provider

This commit is contained in:
ajnart committed 2026-08-12 12:33:04 +02:00
1 parent 0774b02878
commit bffa3e723c
43 files changed
+515 -999

No files matched your search

+4
View File
@@ -22,6 +22,8 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
## Exporting a Backup
![Export database card with the Download Backup action](/img/backup/export-database.png)
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
| File | Description |
@@ -33,6 +35,8 @@ The backup contains the complete database. It includes Custom Widgets and all ot
## Restoring a Backup
![Restore database card with the backup ZIP drop zone](/img/backup/restore-database.png)
### 1. Upload
Drag and drop or select a `.zip` backup file. The import card validates that the file contains a valid `db.sqlite` and metadata.
@@ -130,12 +130,12 @@ The same suites can target the Homarr provider with a Workshop PocketBase token
```sh
AI_PROVIDER_BASE_URL=https://homarr.dev/api/ai/v1 \
AI_PROVIDER_API_KEY=<workshop-token> \
AI_PROVIDER_MODEL=homarr/deepseek-v4-flash-latest \
AI_PROVIDER_MODEL=homarr/model \
pnpm --filter @homarr/custom-widgets test:assistant-ai
```
`AI_PROVIDER_JUDGE_MODEL` can select a separate judge when the configured endpoint offers one. The Homarr provider
offers exactly one model, so generation and review both use `homarr/deepseek-v4-flash-latest`.
offers exactly one model, so generation and review both use `homarr/model`.
The assistant test exposes the same lazy skill, live schema, component catalog, targeted component references,
validation, preview-query, and create-from-preview tools as the product. It refuses to save a definition that was not
@@ -143,8 +143,8 @@ validated exactly or whose preview queries were not all tested. Multi-request fi
each documented path and require manual-query parameters. Use `--case=pokedex`, `--case=coinmarketcap-keyless`,
`--case=bored-activity`, or `--case=agify-name` for a focused run.
Both suites default generation and harsh review to OpenRouter's `~deepseek/deepseek-v4-flash-latest` route. They save
tool traces, validated manifests, raw reviews, and detailed category scores under
Both suites use the configured provider models for generation and harsh review. They save tool traces, validated
manifests, raw reviews, and detailed category scores under
`packages/custom-widgets/.ai-evaluations/`. These live tests are maintainer-invoked rather than CI-required because
they consume a configured provider account and exercise a nondeterministic model. The repository's ordinary tests
cover the lifecycle enforcement, fixture routing, score calculation, and failure behavior without network access.
+11 -12
View File
@@ -8,15 +8,13 @@ tags:
- Community
---
import { AssistantProviderActivity } from "@site/src/components/assistant-provider/assistant-provider-activity";
import { OpenCollectiveLink } from "@site/src/components/assistant-provider/open-collective-link";
# Homarr provider
The Homarr provider gives Workshop users access to **DeepSeek V4 Flash Latest** without an administrator API key.
Each user signs in with GitHub and receives a separate daily allowance.
![Homarr provider configuration](/img/assistant/homarr-provider.png)
The Homarr provider gives Workshop users access to the **Homarr model** without an administrator API key. The Homarr
team selects a capable model based on quality, reliability, and cost, and can change the upstream model without
changing the user-facing provider. Each user signs in with GitHub and receives a separate daily allowance.
## Set it up
@@ -29,25 +27,26 @@ Each user signs in with GitHub and receives a separate daily allowance.
## Daily allowance
- Each user starts with **50 request units per UTC day**.
- A model request uses one unit.
- Each completed Homarr tool call uses one additional unit.
- Each model request uses one unit. Agent tool loops can make multiple model requests.
- The allowance resets at 00:00 UTC.
The battery beside the model selector shows the remaining allowance and reset time. The Workshop server enforces the
limit before it sends a request to OpenRouter.
<AssistantProviderActivity />
## Data and cost
The Workshop keeps the OpenRouter API key on its server. It does not send that key to Homarr or the browser.
Prompts, attachments, tool definitions, and tool results are sent to OpenRouter to generate a response. The Workshop
does not store this content. It stores only request status, model, usage, duration, cost, and timestamps.
Prompts, attachments, tool definitions, and tool results are sent through OpenRouter to generate a response. The
Workshop proxy does not retain that content, responses, or per-request telemetry. It stores only the Workshop user,
UTC day, and counters required to enforce per-user and global daily allowances. Requests require OpenRouter routing
with zero data retention and data collection disabled. Homarr does not use this content to train language models.
We would rather become millionaires in a more ethical manner.
The Homarr community pays for these requests. Donations help cover the cost.
<OpenCollectiveLink />
Workshop operators can change the upstream, default allowance, and security limits in the
Workshop operators can change the upstream model, per-user allowance, global allowance, and security limits in the
[Workshop operator guide](./operator#homarr-provider-proxy).
+23 -16
View File
@@ -37,16 +37,18 @@ store duplicate profile metadata or avatar files. Configure SMTP for comment, re
The public URL contract and Workshop-only server configuration are:
| Variable | Purpose | Default |
| ------------------------------- | ------------------------------------------------- | ------------------------------ |
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
| `OPENROUTER_API_KEY` | Server-only key for the Homarr provider proxy | Provider disabled when empty |
| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Initial per-user daily provider allowance | `50` |
| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible proxy upstream | `https://openrouter.ai/api/v1` |
| Variable | Purpose | Default |
| -------------------------------------- | ------------------------------------------------- | ------------------------------ |
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
| `OPENROUTER_API_KEY` | Server-only key for the Homarr provider proxy | Provider disabled when empty |
| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Per-user daily provider allowance | `50` |
| `HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT` | Shared daily ceiling across all users | `10000` |
| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible proxy upstream | `https://openrouter.ai/api/v1` |
| `HOMARR_AI_OPENROUTER_MODEL` | Server-selected upstream model | Selected by the Homarr team |
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
trailing slashes when Homarr or Docusaurus starts. The deprecated docs-only `WORKSHOP_URL` alias remains for one
@@ -57,13 +59,18 @@ container after changing these values, without rebuilding it.
### Homarr provider proxy
The [Homarr provider](./homarr-provider) proxy is part of the Workshop image. `OPENROUTER_API_KEY` enables the proxy,
`HOMARR_AI_OPENROUTER_BASE_URL` selects its OpenRouter-compatible upstream, and `HOMARR_AI_DAILY_REQUEST_LIMIT` sets the
initial limit stored for each new user. Existing users keep their assigned limits. The upstream URL cannot contain
embedded credentials, a query, or a fragment. Use HTTPS for any upstream outside a trusted private network.
`HOMARR_AI_OPENROUTER_BASE_URL` selects its OpenRouter-compatible upstream, `HOMARR_AI_OPENROUTER_MODEL` selects the
private upstream model behind the stable `homarr/model` alias, and the two allowance variables provide per-user and
shared cost ceilings. Lowering either allowance takes effect for existing users. The upstream URL cannot contain
embedded credentials, a query, or a fragment and must use HTTPS. Tests can explicitly opt into an insecure local mock
with `HOMARR_AI_ALLOW_INSECURE_UPSTREAM=true`; do not use that setting with a real API key.
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic quota enforcement, failed-request
accounting, input and output controls, tool-call accounting, private records, public activity, realtime delivery, and
migration rollback.
The proxy overwrites client routing and generation limits. It requires OpenRouter zero-data-retention routing, denies
data-collecting providers, limits responses to 32,768 completion tokens, and does not retain prompts, responses, or
per-request telemetry. Keep an OpenRouter account-level credit limit as the final operational backstop.
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
failed-request accounting, input and output controls, privacy routing, absence of request logs, and migration rollback.
The Custom Widget AI tests can use this proxy. Set `AI_PROVIDER_BASE_URL` to the `/api/ai/v1` endpoint and
`AI_PROVIDER_API_KEY` to a temporary Workshop user token. See
+1 -1
View File
@@ -362,7 +362,7 @@ html[data-workshop-detail-loading] #workshop-detail-route-fallback {
box-sizing: border-box;
color: #4b5563;
display: flex;
font: 500 0.875rem/1.5 Inter, system-ui, sans-serif;
font: 500 0.875rem/1.5 "Work Sans", system-ui, sans-serif;
justify-content: center;
min-height: 100vh;
padding: 2rem;
@@ -1,165 +0,0 @@
.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: var(--ifm-color-emphasis-500);
}
.live[data-connected] .beep {
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) {
.live[data-connected] .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;
}
}
@@ -1,144 +0,0 @@
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 activityDate = (value: string) => new Date(value.includes("T") ? value : value.replace(" ", "T"));
const mergeActivity = (current: WorkshopAssistantActivity[], next: WorkshopAssistantActivity) =>
[next, ...current.filter((item) => item.id !== next.id)]
.toSorted((left, right) => activityDate(right.created).getTime() - activityDate(left.created).getTime())
.slice(0, 10);
const mergeActivities = (current: WorkshopAssistantActivity[], next: WorkshopAssistantActivity[]) =>
next.reduce(mergeActivity, current);
const getEmptyLabel = (loading: boolean, loadFailed: boolean, connected: boolean) => {
if (loading) return "Loading recent activity…";
if (loadFailed) return connected ? "Waiting for live provider activity…" : "Recent provider activity is unavailable.";
return "No provider requests yet today.";
};
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 [loadFailed, setLoadFailed] = useState(false);
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 nextUnsubscribe = 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) {
unsubscribe = nextUnsubscribe;
setConnected(true);
} else {
nextUnsubscribe();
}
} catch {
if (active) setConnected(false);
}
try {
const initial = await backend.listAssistantActivity(10);
if (active) {
setActivities((current) => mergeActivities(current, initial));
setLoadFailed(false);
}
} catch {
if (active) setLoadFailed(true);
} 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} data-connected={connected || undefined} 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={activityDate(activity.created).toLocaleString()}>
{activityDate(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}>{getEmptyLabel(loading, loadFailed, connected)}</div>
)}
</section>
);
};
@@ -3,23 +3,29 @@ import { IconBrandDocker, IconCpu, IconDeviceDesktopAnalytics } from "@tabler/ic
import styles from "../../../../pages/index.module.css";
import { AppWidget } from "./widgets/app-widget";
import { ClockWidget } from "./widgets/clock-widget";
import { AssistantProviderWidget } from "./widgets/assistant-provider-widget";
import { DownloadsWidget } from "./widgets/downloads-widget";
import { WeatherWidget } from "./widgets/weather-widget";
import { EntityStateWidget } from "./widgets/entity-state-widget";
import { StockWidget } from "./widgets/stock-widget";
import { WeatherWidget } from "./widgets/weather-widget";
import { WidgetCard } from "./widgets/card";
export const HeroCards = () => {
return (
<div className={`argos-ignore ${styles.heroCards}`}>
<div className={`argos-ignore hero-cards ${styles.heroCards}`}>
<StockWidget />
<AppWidget name="Plex" />
<AppWidget name="Jellyfin" />
<AppWidget name="Homeassistant" label="Home Assistant" />
<ClockWidget />
<SystemResourcesWidget />
<WeatherWidget />
<ClockWidget />
<DownloadsWidget />
<AssistantProviderWidget />
<DockerWidget />
<EntityStateWidget />
<AppWidget />
<AppWidget name="PiHole" label="Pi-hole" />
<AppWidget name="qBittorrent" />
<DownloadsWidget />
<DockerWidget />
<AppWidget name="Sonarr" />
<AppWidget name="Radarr" />
</div>
);
};
@@ -35,11 +41,12 @@ const SystemResourcesWidget = () => (
</div>
<ResourceMeter icon={<IconCpu size={15} aria-hidden="true" />} label="CPU" value={34} />
<ResourceMeter label="Memory" value={62} />
<ResourceMeter label="Disk" value={98} />
</WidgetCard>
);
const ResourceMeter = ({ icon, label, value }: { icon?: React.ReactNode; label: string; value: number }) => (
<div className={styles.resourceMeter}>
<div className={`${styles.resourceMeter} ${value >= 90 ? styles.resourceMeterCritical : ""}`}>
<div className={styles.resourceLabel}>
<span>
{icon}
@@ -58,18 +65,21 @@ const DockerWidget = () => (
<IconBrandDocker size={18} aria-hidden="true" />
Docker
</span>
<span className={styles.dockerCount}>3 running</span>
<span className={styles.dockerCount}>2 running · 1 failed</span>
</div>
<div className={styles.containerList}>
{[
["immich", "Healthy"],
["jellyfin", "Running"],
["home-assistant", "Running"],
["paperless", "Exited"],
].map(([name, status]) => (
<div className={styles.containerRow} key={name}>
<div className={`${styles.containerRow} ${status === "Exited" ? styles.containerRowFailed : ""}`} key={name}>
<span className={styles.containerName}>{name}</span>
<span className={styles.containerStatus}>
<span className={styles.statusDot} aria-hidden="true" />
<span
className={`${styles.statusDot} ${status === "Exited" ? styles.statusDotFailed : ""}`}
aria-hidden="true"
/>
{status}
</span>
</div>
@@ -57,9 +57,9 @@ export default function HomeHero() {
</Link>
</div>
</div>
<div className={styles.heroPreview} aria-label="Example Homarr dashboard">
<section className={clsx(styles.heroPreview, "hover-animation")} aria-label="Example Homarr dashboard">
<HeroCards />
</div>
</section>
</div>
</header>
);
@@ -1,20 +1,21 @@
import { supportedIntegrations } from "@site/src/constants/supported-integrations";
import { CommonWidgetProps, WidgetCard } from "./card";
import { getRndInteger } from "@site/src/tools/math";
import clsx from "clsx";
export const AppWidget = ({ className }: CommonWidgetProps) => {
const randomApp = supportedIntegrations[getRndInteger(0, supportedIntegrations.length - 1)];
interface AppWidgetProps extends CommonWidgetProps {
name: string;
label?: string;
}
export const AppWidget = ({ name, label, className }: AppWidgetProps) => {
const app = supportedIntegrations.find((integration) => integration.name === name);
if (!app) return null;
return (
<WidgetCard width={1} className={clsx("text-center", className)}>
<span className={"text-sm font-bold"}>{randomApp.name}</span>
<img
src={randomApp.iconUrl}
className="aspect-square scale-[0.6] transition-transform"
alt={`${randomApp.name} Icon`}
/>
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2"></div>
<WidgetCard width={1} className={clsx("app-card text-center", className)}>
<span className={"text-sm font-bold"}>{label ?? app.name}</span>
<img src={app.iconUrl} className="aspect-square scale-[0.6]" alt={`${label ?? app.name} icon`} />
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2" aria-hidden="true" />
</WidgetCard>
);
};
@@ -1,11 +0,0 @@
import { AssistantProviderActivity } from "@site/src/components/assistant-provider/assistant-provider-activity";
import clsx from "clsx";
import type { CommonWidgetProps } from "./card";
import { WidgetCard } from "./card";
export const AssistantProviderWidget = ({ className }: CommonWidgetProps) => (
<WidgetCard width={2} className={clsx("!items-stretch !justify-stretch overflow-hidden !p-0", className)}>
<AssistantProviderActivity compact />
</WidgetCard>
);
@@ -18,11 +18,11 @@ export const WidgetCard = ({ width, children, className, onClick }: WidgetCardPr
<Component
className={clsx(
styles.widgetCard,
width === 2 ? styles.widgetCardWide : styles.widgetCardSingle,
"flex flex-col justify-center items-center p-2 relative border-none bg-[var(--ifm-navbar-background-color)]",
className,
onClick ? "cursor-pointer" : undefined,
)}
style={{ width: (128 * width + (width === 2 ? 16 : 0)) * 0.9, height: 128 }}
type={onClick ? "button" : undefined}
onClick={onClick}
>
@@ -27,6 +27,7 @@ export const StockWidget = ({ className }: CommonWidgetProps) => {
HOMR
</div>
<Line
ariaLabel="Simulated HOMR stock price trend"
height={128}
width={244}
colors={[upwardTrend ? "green" : "red"]}
+8 -4
View File
@@ -1,4 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap");
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap");
@import "tailwindcss/theme" layer(theme);
@import "tailwindcss/utilities" layer(utilities);
@@ -10,7 +10,7 @@
--breakpoint-3xl: 1697px;
--font-heading: var(--font-sans);
--font-sans: "Inter", sans-serif;
--font-sans: "Work Sans", sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -137,7 +137,7 @@
--sidebar-border: oklch(0.9276 0.0058 264.5313);
--sidebar-ring: oklch(0.56 0.205 25.6509);
--destructive-foreground: oklch(0.9851 0 0);
--font-sans: "Inter", sans-serif;
--font-sans: "Work Sans", sans-serif;
--font-serif: Georgia, serif;
--font-mono: "JetBrains Mono", monospace;
--shadow-color: #000000;
@@ -491,6 +491,10 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
}
@media (prefers-reduced-motion: reduce) {
.hover-animation {
animation: none;
}
.submit-step-enter {
animation: none;
}
@@ -541,7 +545,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
--sidebar-accent-foreground: oklch(0.6646 0.2222 25.6509);
--sidebar-border: oklch(0.2739 0.0055 286.0326);
--sidebar-ring: oklch(0.72 0.16 25.6509);
--font-sans: "Inter", sans-serif;
--font-sans: "Work Sans", sans-serif;
--font-serif: Georgia, serif;
--font-mono: "JetBrains Mono", monospace;
--shadow-color: #000000;
+55 -21
View File
@@ -35,10 +35,10 @@ section {
.heroContent {
display: grid;
grid-template-columns: minmax(24rem, 1fr) minmax(31.25rem, 0.9fr);
grid-template-columns: minmax(25rem, 1fr) minmax(44rem, 48rem);
align-items: center;
gap: clamp(2rem, 5vw, 5rem);
max-width: 90rem;
gap: clamp(1rem, 3vw, 3rem);
max-width: 108rem;
margin-inline: auto;
}
@@ -61,10 +61,10 @@ section {
}
.heroCards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
width: min(100%, 31.25rem);
display: flex;
flex-wrap: wrap;
gap: 1rem 0.75rem;
width: min(100%, 48rem);
margin-inline: auto;
color: #343a40;
}
@@ -74,19 +74,15 @@ section {
}
.widgetCard {
height: 8rem;
flex: none;
min-width: 0;
border-radius: 0.75rem;
background: color-mix(in srgb, var(--ifm-navbar-background-color) 88%, transparent) !important;
box-shadow: 0 4px 8px rgb(31 41 55 / 12%);
}
.widgetCardSingle {
grid-column: span 1;
}
.widgetCardWide {
grid-column: span 2;
border: 1px solid color-mix(in srgb, currentColor 12%, transparent) !important;
color: inherit;
font: inherit;
background: color-mix(in srgb, var(--ifm-navbar-background-color) 76%, transparent) !important;
backdrop-filter: blur(12px);
box-shadow: 0 12px 28px rgb(31 41 55 / 14%);
}
.resourcesWidget,
@@ -135,6 +131,11 @@ section {
gap: 0.25rem;
}
.resourceMeterCritical .resourceLabel {
color: #a51111;
font-weight: 700;
}
.resourceLabel {
font-size: 0.6875rem;
}
@@ -167,6 +168,11 @@ section {
background: var(--ifm-color-primary);
}
.resourceMeterCritical .resourceTrack::-webkit-progress-value,
.resourceMeterCritical .resourceTrack::-moz-progress-bar {
background: #e03131;
}
.downloadProgress {
display: block;
appearance: none;
@@ -219,7 +225,29 @@ section {
background: #12b886;
}
@media screen and (max-width: 996px) {
.containerRowFailed {
color: #a51111;
font-weight: 700;
}
:global([data-theme="dark"]) .resourceMeterCritical .resourceLabel,
:global([data-theme="dark"]) .containerRowFailed {
color: #ffe3e3;
}
.statusDotFailed {
background: #fa5252;
animation: failed-container-pulse 1.4s ease-in-out infinite;
}
@keyframes failed-container-pulse {
50% {
opacity: 0.35;
transform: scale(1.65);
}
}
@media screen and (max-width: 1280px) {
.heroBanner {
padding: 3rem 1.5rem;
}
@@ -234,11 +262,11 @@ section {
}
.heroCards {
width: min(100%, 36rem);
width: min(100%, 48rem);
}
}
@media screen and (max-width: 640px) {
@media screen and (max-width: 996px) {
.heroBanner {
padding: 2.5rem 1rem;
}
@@ -251,3 +279,9 @@ section {
flex: 1 1 auto;
}
}
@media (prefers-reduced-motion: reduce) {
.statusDotFailed {
animation: none;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

-1
View File
@@ -97,7 +97,6 @@
"isomorphic-dompurify": "catalog:",
"jotai": "catalog:",
"jszip": "catalog:",
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"mermaid": "catalog:",
@@ -447,6 +447,7 @@ export const AssistantConfiguration = () => {
description={t("baseUrl.description")}
leftSection={<IconWorld size={16} />}
value={baseUrl}
disabled={isHomarrProvider}
onChange={(event) => changeBaseUrl(event.currentTarget.value)}
placeholder="https://provider.example/v1"
/>
@@ -454,6 +455,7 @@ export const AssistantConfiguration = () => {
label={t("model.discoveryPath")}
description={t("model.discoveryPathDescription")}
value={modelDiscoveryPath}
disabled={isHomarrProvider}
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
placeholder="/models"
/>
@@ -73,7 +73,6 @@ import {
useComputedColorScheme,
} from "@mantine/core";
import { useReducedMotion, useWindowEvent } from "@mantine/hooks";
import { BatteryFull, BatteryLow, BatteryMedium, BatteryWarning } from "lucide-react";
import {
IconActivityHeartbeat,
IconAlertTriangle,
@@ -81,6 +80,10 @@ import {
IconArrowUp,
IconArrowsMaximize,
IconAt,
IconBattery1,
IconBattery3,
IconBatteryFilled,
IconBatteryOff,
IconCheck,
IconChevronDown,
IconChevronLeft,
@@ -2543,10 +2546,10 @@ const RuntimeControls = ({
};
const providerQuotaIcons = {
ok: BatteryFull,
warning: BatteryMedium,
bad: BatteryLow,
dead: BatteryWarning,
ok: IconBatteryFilled,
warning: IconBattery3,
bad: IconBattery1,
dead: IconBatteryOff,
};
const providerQuotaColors = {
+3
View File
@@ -10,7 +10,10 @@ WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
# Optional Homarr provider. The upstream key stays only in the Workshop server.
# OPENROUTER_API_KEY=
HOMARR_AI_DAILY_REQUEST_LIMIT=50
HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT=10000
# HOMARR_AI_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# HOMARR_AI_OPENROUTER_MODEL=
# HOMARR_AI_ALLOW_INSECURE_UPSTREAM=false
# Optional local GitHub OAuth application.
# GITHUB_CLIENT_ID=
+2
View File
@@ -10,7 +10,9 @@ PB_ALLOWED_ORIGINS=*
# Set this secret in the deployment environment to enable the Homarr provider.
# OPENROUTER_API_KEY=
HOMARR_AI_DAILY_REQUEST_LIMIT=50
HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT=10000
# HOMARR_AI_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# HOMARR_AI_OPENROUTER_MODEL=
# Optional GitHub OAuth application for the preview hostname.
# GITHUB_CLIENT_ID=
+2
View File
@@ -14,7 +14,9 @@ services:
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
HOMARR_AI_DAILY_REQUEST_LIMIT: ${HOMARR_AI_DAILY_REQUEST_LIMIT:-50}
HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT: ${HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT:-10000}
HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
HOMARR_AI_OPENROUTER_MODEL: ${HOMARR_AI_OPENROUTER_MODEL:-~deepseek/deepseek-v4-flash-latest}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
ports:
- "127.0.0.1:${WORKSHOP_EXPOSE_PORT:-8090}:8090"
+7
View File
@@ -8,6 +8,7 @@ services:
command: ["node", "/mock/openrouter-mock.mjs"]
environment:
PORT: 18091
EXPECTED_MODEL: mock/team-selected-model
volumes:
- ./tests/openrouter-mock.mjs:/mock/openrouter-mock.mjs:ro
security_opt:
@@ -28,7 +29,10 @@ services:
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
HOMARR_AI_DAILY_REQUEST_LIMIT: ${HOMARR_AI_DAILY_REQUEST_LIMIT:-50}
HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT: ${HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT:-10000}
HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
HOMARR_AI_OPENROUTER_MODEL: ${HOMARR_AI_OPENROUTER_MODEL:-~deepseek/deepseek-v4-flash-latest}
HOMARR_AI_ALLOW_INSECURE_UPSTREAM: ${HOMARR_AI_ALLOW_INSECURE_UPSTREAM:-false}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003}
ports:
@@ -60,7 +64,10 @@ services:
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
HOMARR_AI_DAILY_REQUEST_LIMIT: ${HOMARR_AI_DAILY_REQUEST_LIMIT:-50}
HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT: ${HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT:-10000}
HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
HOMARR_AI_OPENROUTER_MODEL: ${HOMARR_AI_OPENROUTER_MODEL:-~deepseek/deepseek-v4-flash-latest}
HOMARR_AI_ALLOW_INSECURE_UPSTREAM: ${HOMARR_AI_ALLOW_INSECURE_UPSTREAM:-false}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003}
ports:
+149 -206
View File
@@ -6,13 +6,13 @@ import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"unicode"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
@@ -21,18 +21,22 @@ import (
)
const (
homarrProviderModelID = "homarr/deepseek-v4-flash-latest"
openRouterModelID = "~deepseek/deepseek-v4-flash-latest"
maxChatInputTokens = 256 * 1024
maxChatOutputTokens = 32 * 1024
defaultDailyLimit = 50
maxProviderRequestUnits = 1000
// Do not trust a client-side token estimate. Applying the same numeric ceiling to
// the complete serialized payload is deliberately stricter than a tokenizer-based
// limit and also bounds attachments and tool schemas.
maxChatRequestBytes = maxChatInputTokens
homarrProviderModelID = "homarr/model"
homarrProviderModelName = "Homarr model"
defaultOpenRouterModelID = "~deepseek/deepseek-v4-flash-latest"
maxChatInputTokens = 256 * 1024
maxChatOutputTokens = 32 * 1024
defaultDailyLimit = 50
defaultGlobalDailyLimit = 10_000
// The Homarr composer permits five 1 MB images, which expand to about 6.7 MB as
// base64. Keep the authenticated transport bounded without confusing bytes with
// model tokens; validate textual and image content separately below.
maxChatRequestBytes = 12_000_000
maxChatTextBytes = 4 * maxChatInputTokens
maxChatImageDataBytes = 1_400_000
maxChatImages = 5
maxChatMessages = 512
maxChatResponseBytes = 8 << 20
maxUpstreamErrorBytes = 1 << 20
maxWebSearchResults = 5
maxWebSearchUses = 3
openRouterDefaultURL = "https://openrouter.ai/api/v1"
@@ -45,12 +49,18 @@ var clientControlledRoutingFields = []string{
"models", "provider", "route", "plugins", "transforms", "extra_body", "extra_headers",
}
var clientControlledCostFields = []string{
"audio", "modalities", "logprobs", "top_logprobs", "prediction", "service_tier",
}
type homarrProvider struct {
apiKey string
baseURL string
dailyLimit int
httpClient *http.Client
now func() time.Time
apiKey string
baseURL string
modelID string
dailyLimit int
globalDailyLimit int
httpClient *http.Client
now func() time.Time
}
type quotaSnapshot struct {
@@ -60,13 +70,6 @@ type quotaSnapshot struct {
ResetsAt time.Time `json:"resetsAt"`
}
type requestUsage struct {
InputTokens int
OutputTokens int
TotalTokens int
Cost float64
}
type providerRequest struct {
Model string `json:"model"`
Messages []providerMessage `json:"messages"`
@@ -92,7 +95,9 @@ func registerHomarrProvider(app *pocketbase.PocketBase) {
app.OnServe().BindFunc(func(event *core.ServeEvent) error {
group := event.Router.Group("/api/ai")
group.GET("/v1/models", provider.models)
group.GET("/usage", provider.usage).Bind(apis.RequireAuth("users"))
group.GET("/usage", provider.usage).
Bind(apis.RequireAuth("users")).
Unbind(apis.DefaultActivityLoggerMiddlewareId)
group.POST("/v1/chat/completions", provider.chat).
Bind(apis.RequireAuth("users"), apis.BodyLimit(maxChatRequestBytes)).
Unbind(apis.DefaultActivityLoggerMiddlewareId)
@@ -110,6 +115,19 @@ func newHomarrProviderFromEnv() (*homarrProvider, error) {
parsedURL.User != nil || parsedURL.RawQuery != "" || parsedURL.Fragment != "" {
return nil, errors.New("HOMARR_AI_OPENROUTER_BASE_URL must be an absolute HTTP(S) URL without credentials, a query, or a fragment")
}
if parsedURL.Scheme != "https" && strings.TrimSpace(os.Getenv("HOMARR_AI_ALLOW_INSECURE_UPSTREAM")) != "true" {
return nil, errors.New("HOMARR_AI_OPENROUTER_BASE_URL must use HTTPS unless HOMARR_AI_ALLOW_INSECURE_UPSTREAM=true")
}
modelID := strings.TrimSpace(os.Getenv("HOMARR_AI_OPENROUTER_MODEL"))
if modelID == "" {
modelID = defaultOpenRouterModelID
}
if len(modelID) > 256 || strings.IndexFunc(modelID, func(value rune) bool {
return unicode.IsSpace(value) || unicode.IsControl(value)
}) >= 0 {
return nil, errors.New("HOMARR_AI_OPENROUTER_MODEL must be a non-empty model identifier of at most 256 characters")
}
dailyLimit := defaultDailyLimit
if rawLimit := strings.TrimSpace(os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")); rawLimit != "" {
@@ -119,11 +137,21 @@ func newHomarrProviderFromEnv() (*homarrProvider, error) {
}
dailyLimit = value
}
globalDailyLimit := defaultGlobalDailyLimit
if rawLimit := strings.TrimSpace(os.Getenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT")); rawLimit != "" {
value, parseErr := strconv.Atoi(rawLimit)
if parseErr != nil || value < 1 || value > 10_000_000 {
return nil, errors.New("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT must be an integer between 1 and 10000000")
}
globalDailyLimit = value
}
return &homarrProvider{
apiKey: strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")),
baseURL: baseURL,
dailyLimit: dailyLimit,
apiKey: strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")),
baseURL: baseURL,
modelID: modelID,
dailyLimit: dailyLimit,
globalDailyLimit: globalDailyLimit,
httpClient: &http.Client{
Timeout: providerRequestTimeout,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
@@ -143,8 +171,8 @@ func (provider *homarrProvider) models(event *core.RequestEvent) error {
"data": []map[string]any{{
"id": homarrProviderModelID,
"object": "model",
"name": "DeepSeek V4 Flash Latest",
"description": "Fast agentic model provided by the community-funded Homarr provider.",
"name": homarrProviderModelName,
"description": "The tool-capable model selected by the Homarr team.",
"owned_by": "homarr",
"context_length": maxChatInputTokens,
"supported_parameters": []string{
@@ -180,20 +208,21 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
if err := json.Unmarshal(body, &requestBody); err != nil || len(requestBody.Messages) == 0 {
return event.JSON(http.StatusBadRequest, openAIError("The request body is not valid OpenAI chat JSON."))
}
if requestBody.Model != homarrProviderModelID {
return event.JSON(http.StatusBadRequest, openAIError("The Homarr provider offers only DeepSeek V4 Flash Latest."))
if len(requestBody.Messages) > maxChatMessages {
return event.JSON(http.StatusBadRequest, openAIError("The request contains too many messages."))
}
requestUnits := countRequestUnits(requestBody.Messages)
if requestUnits > maxProviderRequestUnits {
return event.JSON(http.StatusBadRequest, openAIError("A single Homarr provider request can use at most 1000 request units."))
if requestBody.Model != homarrProviderModelID {
return event.JSON(http.StatusBadRequest, openAIError("The Homarr provider offers only the Homarr model."))
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return event.JSON(http.StatusBadRequest, openAIError("The request body is not valid OpenAI chat JSON."))
}
if err := sanitizeProviderPayload(payload); err != nil {
if err := validateProviderInput(payload); err != nil {
return event.JSON(http.StatusRequestEntityTooLarge, openAIError(err.Error()))
}
if err := sanitizeProviderPayload(payload, provider.modelID); err != nil {
return event.JSON(http.StatusBadRequest, openAIError(err.Error()))
}
upstreamBody, err := json.Marshal(payload)
@@ -201,7 +230,7 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
return event.JSON(http.StatusBadRequest, openAIError("The request body could not be prepared."))
}
snapshot, privateRequest, publicActivity, err := provider.startRequest(event.App, event.Auth, requestUnits)
snapshot, err := provider.startRequest(event.App, event.Auth)
if err != nil {
var quotaErr *quotaExceededError
if errors.As(err, &quotaErr) {
@@ -212,7 +241,6 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
}
return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be started."))
}
startedAt := provider.now().UTC()
upstreamRequest, err := http.NewRequestWithContext(
event.Request.Context(),
http.MethodPost,
@@ -220,7 +248,6 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
bytes.NewReader(upstreamBody),
)
if err != nil {
provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt)
return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be prepared."))
}
upstreamRequest.Header.Set("Authorization", "Bearer "+provider.apiKey)
@@ -232,7 +259,6 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
upstreamResponse, err := provider.httpClient.Do(upstreamRequest)
if err != nil {
provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt)
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint could not be reached."))
}
defer func() { _ = upstreamResponse.Body.Close() }()
@@ -251,34 +277,32 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
contentType = "application/json"
}
if upstreamResponse.StatusCode < 200 || upstreamResponse.StatusCode >= 300 {
provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt)
return event.JSON(upstreamResponse.StatusCode, openAIError("The model endpoint rejected the request."))
}
if requestBody.Stream {
capture := newSSEUsageCapture()
event.Response.Header().Set("Content-Type", contentType)
event.Response.WriteHeader(upstreamResponse.StatusCode)
_, copyErr := copyBounded(flushingWriter{event.Response}, io.TeeReader(upstreamResponse.Body, capture), maxChatResponseBytes)
provider.finishActivity(event.App, privateRequest, publicActivity, statusForError(copyErr), capture.Usage(), startedAt)
_, _ = copyBounded(flushingWriter{event.Response}, upstreamResponse.Body, maxChatResponseBytes)
return nil
}
responseBody, readErr := readBoundedBody(upstreamResponse.Body, maxChatResponseBytes)
if readErr != nil {
provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt)
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint returned an invalid response."))
}
usage := usageFromJSON(responseBody)
provider.finishActivity(event.App, privateRequest, publicActivity, "completed", usage, startedAt)
return event.Blob(upstreamResponse.StatusCode, contentType, responseBody)
}
func sanitizeProviderPayload(payload map[string]any) error {
func sanitizeProviderPayload(payload map[string]any, upstreamModelID string) error {
for _, field := range clientControlledRoutingFields {
delete(payload, field)
}
for _, field := range clientControlledCostFields {
delete(payload, field)
}
delete(payload, "max_completion_tokens")
delete(payload, "max_tokens")
delete(payload, "metadata")
delete(payload, "user")
if reasoning, ok := payload["reasoning"].(map[string]any); ok {
@@ -318,14 +342,55 @@ func sanitizeProviderPayload(payload map[string]any) error {
payload["tools"] = sanitizedTools
}
payload["model"] = openRouterModelID
payload["max_tokens"] = maxChatOutputTokens
payload["model"] = upstreamModelID
payload["max_completion_tokens"] = maxChatOutputTokens
payload["n"] = 1
payload["parallel_tool_calls"] = false
payload["usage"] = map[string]any{"include": true}
payload["provider"] = map[string]any{"zdr": true, "data_collection": "deny"}
return nil
}
func validateProviderInput(payload map[string]any) error {
textBytes := 0
imageCount := 0
var walk func(any) error
walk = func(value any) error {
switch typed := value.(type) {
case string:
if strings.HasPrefix(typed, "data:image/") {
imageCount++
if imageCount > maxChatImages || len(typed) > maxChatImageDataBytes {
return errors.New("The request contains too many images or an image that is too large.")
}
return nil
}
textBytes += len(typed)
if textBytes > maxChatTextBytes {
return errors.New("The request exceeds the Homarr model input limit.")
}
case []any:
for _, item := range typed {
if err := walk(item); err != nil {
return err
}
}
case map[string]any:
for key, item := range typed {
textBytes += len(key)
if textBytes > maxChatTextBytes {
return errors.New("The request exceeds the Homarr model input limit.")
}
if err := walk(item); err != nil {
return err
}
}
}
return nil
}
return walk(payload)
}
func copyBounded(writer io.Writer, reader io.Reader, limit int64) (int64, error) {
written, err := io.Copy(writer, io.LimitReader(reader, limit))
if err != nil {
@@ -353,14 +418,6 @@ func readBoundedBody(reader io.Reader, limit int64) ([]byte, error) {
return body, nil
}
func countRequestUnits(messages []providerMessage) int {
units := 1
for index := len(messages) - 1; index >= 0 && messages[index].Role == "tool"; index-- {
units++
}
return units
}
func (provider *homarrProvider) quota(app core.App, user *core.Record) (quotaSnapshot, error) {
now := provider.now().UTC()
var snapshot quotaSnapshot
@@ -369,12 +426,32 @@ func (provider *homarrProvider) quota(app core.App, user *core.Record) (quotaSna
if err != nil {
return err
}
globalQuota, globalRemaining, err := provider.loadGlobalQuota(txApp, now)
if err != nil {
return err
}
current.Remaining = min(current.Remaining, globalRemaining)
snapshot = current
if err := txApp.Save(globalQuota); err != nil {
return err
}
return txApp.Save(quota)
})
return snapshot, err
}
func (provider *homarrProvider) loadGlobalQuota(app core.App, now time.Time) (*core.Record, int, error) {
quota, err := app.FindFirstRecordByFilter("assistant_global_quota", "key = 'default'")
if err != nil {
return nil, 0, err
}
if quota.GetString("day") != now.Format(time.DateOnly) {
quota.Set("day", now.Format(time.DateOnly))
quota.Set("used", 0)
}
return quota, max(0, provider.globalDailyLimit-quota.GetInt("used")), nil
}
func (provider *homarrProvider) loadQuota(app core.App, user *core.Record, now time.Time) (*core.Record, quotaSnapshot, error) {
quota, err := app.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
@@ -387,16 +464,12 @@ func (provider *homarrProvider) loadQuota(app core.App, user *core.Record, now t
}
quota = core.NewRecord(collection)
quota.Set("user", user.Id)
quota.Set("dailyLimit", provider.dailyLimit)
}
if quota.GetString("day") != now.Format(time.DateOnly) {
quota.Set("day", now.Format(time.DateOnly))
quota.Set("used", 0)
quota.Set("inputTokens", 0)
quota.Set("outputTokens", 0)
quota.Set("totalTokens", 0)
}
limit := quota.GetInt("dailyLimit")
limit := provider.dailyLimit
used := quota.GetInt("used")
return quota, quotaSnapshot{
Limit: limit,
@@ -409,104 +482,32 @@ func (provider *homarrProvider) loadQuota(app core.App, user *core.Record, now t
func (provider *homarrProvider) startRequest(
app core.App,
user *core.Record,
requestUnits int,
) (quotaSnapshot, *core.Record, *core.Record, error) {
) (quotaSnapshot, error) {
var snapshot quotaSnapshot
var privateRequest *core.Record
var publicActivity *core.Record
err := app.RunInTransaction(func(txApp core.App) error {
quota, current, err := provider.loadQuota(txApp, user, provider.now().UTC())
now := provider.now().UTC()
quota, current, err := provider.loadQuota(txApp, user, now)
if err != nil {
return err
}
if requestUnits > current.Remaining {
globalQuota, globalRemaining, err := provider.loadGlobalQuota(txApp, now)
if err != nil {
return err
}
if current.Remaining < 1 || globalRemaining < 1 {
return &quotaExceededError{Snapshot: current}
}
privateCollection, err := txApp.FindCollectionByNameOrId("assistant_requests")
if err != nil {
return err
}
publicCollection, err := txApp.FindCollectionByNameOrId("assistant_activity")
if err != nil {
return err
}
privateRequest = core.NewRecord(privateCollection)
publicActivity = core.NewRecord(publicCollection)
for _, record := range []*core.Record{privateRequest, publicActivity} {
record.Set("status", "processing")
record.Set("model", homarrProviderModelID)
record.Set("requestUnits", requestUnits)
}
privateRequest.Set("user", user.Id)
if err := txApp.Save(publicActivity); err != nil {
return err
}
privateRequest.Set("publicActivity", publicActivity.Id)
if err := txApp.Save(privateRequest); err != nil {
return err
}
current.Used += requestUnits
current.Used++
current.Remaining = max(0, current.Limit-current.Used)
quota.Set("used", current.Used)
globalQuota.Set("used", globalQuota.GetInt("used")+1)
snapshot = current
return txApp.Save(quota)
})
return snapshot, privateRequest, publicActivity, err
}
func (provider *homarrProvider) finishActivity(
app core.App,
privateRequest *core.Record,
publicActivity *core.Record,
status string,
usage requestUsage,
startedAt time.Time,
) {
durationMs := max(0, int(provider.now().UTC().Sub(startedAt).Milliseconds()))
err := app.RunInTransaction(func(txApp core.App) error {
for _, record := range []*core.Record{privateRequest, publicActivity} {
record.Set("status", status)
record.Set("inputTokens", usage.InputTokens)
record.Set("outputTokens", usage.OutputTokens)
record.Set("totalTokens", usage.TotalTokens)
record.Set("durationMs", durationMs)
record.Set("cost", usage.Cost)
if err := txApp.Save(record); err != nil {
return err
}
}
quota, err := txApp.FindFirstRecordByFilter(
"assistant_quotas",
"user = {:user}",
dbx.Params{"user": privateRequest.GetString("user")},
)
if err != nil {
if err := txApp.Save(globalQuota); err != nil {
return err
}
if !requestBelongsToQuotaDay(quota.GetString("day"), startedAt) {
return nil
}
quota.Set("inputTokens", quota.GetInt("inputTokens")+usage.InputTokens)
quota.Set("outputTokens", quota.GetInt("outputTokens")+usage.OutputTokens)
quota.Set("totalTokens", quota.GetInt("totalTokens")+usage.TotalTokens)
return txApp.Save(quota)
})
if err != nil {
log.Printf("Homarr provider accounting update failed for status %q", status)
}
}
func requestBelongsToQuotaDay(day string, startedAt time.Time) bool {
return day == startedAt.UTC().Format(time.DateOnly)
}
func statusForError(err error) string {
if err != nil {
return "failed"
}
return "completed"
return snapshot, err
}
func openAIError(message string) map[string]any {
@@ -524,61 +525,3 @@ func (writer flushingWriter) Write(chunk []byte) (int, error) {
}
return written, err
}
type sseUsageCapture struct {
pending []byte
usage requestUsage
}
func newSSEUsageCapture() *sseUsageCapture { return &sseUsageCapture{} }
func (capture *sseUsageCapture) Write(chunk []byte) (int, error) {
capture.pending = append(capture.pending, chunk...)
for {
end := bytes.IndexByte(capture.pending, '\n')
if end < 0 {
break
}
line := bytes.TrimSuffix(capture.pending[:end], []byte{'\r'})
if bytes.HasPrefix(line, []byte("data: ")) {
capture.mergeUsage(bytes.TrimPrefix(line, []byte("data: ")))
}
capture.pending = capture.pending[end+1:]
}
if len(capture.pending) > maxUpstreamErrorBytes {
capture.pending = nil
}
return len(chunk), nil
}
func (capture *sseUsageCapture) mergeUsage(data []byte) {
if bytes.Equal(data, []byte("[DONE]")) || !bytes.Contains(data, []byte(`"usage"`)) {
return
}
usage := usageFromJSON(data)
if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.TotalTokens != 0 || usage.Cost != 0 {
capture.usage = usage
}
}
func (capture *sseUsageCapture) Usage() requestUsage { return capture.usage }
func usageFromJSON(data []byte) requestUsage {
var payload struct {
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
Cost float64 `json:"cost"`
} `json:"usage"`
}
if json.Unmarshal(data, &payload) != nil {
return requestUsage{}
}
return requestUsage{
InputTokens: payload.Usage.PromptTokens,
OutputTokens: payload.Usage.CompletionTokens,
TotalTokens: payload.Usage.TotalTokens,
Cost: payload.Usage.Cost,
}
}
+46 -76
View File
@@ -2,10 +2,8 @@ package main
import (
"encoding/json"
"math"
"strings"
"testing"
"time"
)
func TestSanitizeProviderPayload(t *testing.T) {
@@ -23,6 +21,12 @@ func TestSanitizeProviderPayload(t *testing.T) {
"extra_body": map[string]any{"provider": map[string]any{"order": []string{"attacker"}}},
"extra_headers": map[string]any{"Authorization": "Bearer attacker"},
"metadata": map[string]any{"user": "private-user-id"},
"audio": map[string]any{"format": "wav"},
"modalities": []string{"text", "audio"},
"logprobs": true,
"top_logprobs": 20,
"prediction": map[string]any{"content": strings.Repeat("x", 100)},
"service_tier": "priority",
"reasoning": map[string]any{"effort": "high", "max_tokens": 1_000_000},
"user": "private-user-id",
"tools": []any{
@@ -35,22 +39,30 @@ func TestSanitizeProviderPayload(t *testing.T) {
},
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
}
if err := sanitizeProviderPayload(payload); err != nil {
if err := sanitizeProviderPayload(payload, "mock/team-selected-model"); err != nil {
t.Fatal(err)
}
for _, field := range clientControlledRoutingFields {
if field == "provider" {
continue
}
if _, exists := payload[field]; exists {
t.Fatalf("client-controlled routing field %q was forwarded", field)
}
}
if payload["model"] != openRouterModelID {
for _, field := range clientControlledCostFields {
if _, exists := payload[field]; exists {
t.Fatalf("client-controlled cost field %q was forwarded", field)
}
}
if payload["model"] != "mock/team-selected-model" {
t.Fatalf("unexpected upstream model: %v", payload["model"])
}
if payload["max_tokens"] != maxChatOutputTokens || payload["n"] != 1 || payload["parallel_tool_calls"] != false {
if payload["max_completion_tokens"] != maxChatOutputTokens || payload["n"] != 1 || payload["parallel_tool_calls"] != false {
t.Fatalf("unsafe generation controls were forwarded: %#v", payload)
}
if _, exists := payload["max_completion_tokens"]; exists {
t.Fatal("client max_completion_tokens was forwarded")
if _, exists := payload["max_tokens"]; exists {
t.Fatal("deprecated client max_tokens was forwarded")
}
if _, exists := payload["user"]; exists {
t.Fatal("client user identifier was forwarded")
@@ -75,86 +87,50 @@ func TestSanitizeProviderPayload(t *testing.T) {
if err != nil || string(usage) != `{"include":true}` {
t.Fatalf("unexpected usage options: %s, %v", usage, err)
}
privacy := payload["provider"].(map[string]any)
if privacy["zdr"] != true || privacy["data_collection"] != "deny" {
t.Fatalf("upstream privacy controls were not enforced: %#v", privacy)
}
}
func TestSanitizeProviderPayloadRejectsUnsupportedServerTools(t *testing.T) {
payload := map[string]any{"tools": []any{map[string]any{"type": "openrouter:computer"}}}
if err := sanitizeProviderPayload(payload); err == nil {
if err := sanitizeProviderPayload(payload, "mock/team-selected-model"); err == nil {
t.Fatal("expected unsupported server tool to be rejected")
}
}
func TestCountRequestUnits(t *testing.T) {
tests := []struct {
name string
messages []providerMessage
want int
}{
{name: "user request", messages: []providerMessage{{Role: "user"}}, want: 1},
{
name: "parallel tool results",
messages: []providerMessage{
{Role: "user"},
{Role: "assistant"},
{Role: "tool"},
{Role: "tool"},
},
want: 3,
},
{
name: "historical tools are not charged again",
messages: []providerMessage{
{Role: "user"},
{Role: "assistant"},
{Role: "tool"},
{Role: "assistant"},
{Role: "user"},
},
want: 1,
},
func TestValidateProviderInput(t *testing.T) {
payload := map[string]any{
"messages": []any{map[string]any{
"role": "user",
"content": []any{map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": "data:image/png;base64," + strings.Repeat("a", 1_300_000)},
}},
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := countRequestUnits(test.messages); got != test.want {
t.Fatalf("countRequestUnits() = %d, want %d", got, test.want)
}
})
if err := validateProviderInput(payload); err != nil {
t.Fatalf("expected a supported Homarr image request, got %v", err)
}
}
func TestSSEUsageCaptureHandlesFragmentedEvents(t *testing.T) {
capture := newSSEUsageCapture()
chunks := []string{
"data: {\"id\":\"gen-test\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n",
"data: {\"usage\":{\"prompt_tokens\":12,\"completion_",
"tokens\":7,\"total_tokens\":19,\"cost\":0.00041}}\n\n",
"data: [DONE]\n\n",
}
for _, chunk := range chunks {
if _, err := capture.Write([]byte(chunk)); err != nil {
t.Fatal(err)
}
}
usage := capture.Usage()
if usage.InputTokens != 12 || usage.OutputTokens != 7 || usage.TotalTokens != 19 {
t.Fatalf("unexpected usage: %+v", usage)
}
if math.Abs(usage.Cost-0.00041) > 0.0000001 {
t.Fatalf("unexpected cost: %f", usage.Cost)
payload["messages"] = []any{map[string]any{"role": "user", "content": strings.Repeat("x", maxChatTextBytes+1)}}
if err := validateProviderInput(payload); err == nil {
t.Fatal("expected oversized text input to be rejected")
}
}
func TestProviderEnvironment(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("HOMARR_AI_OPENROUTER_BASE_URL", "https://router.example/v1/")
t.Setenv("HOMARR_AI_OPENROUTER_MODEL", "mock/team-selected-model")
t.Setenv("HOMARR_AI_DAILY_REQUEST_LIMIT", "75")
t.Setenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT", "900")
provider, err := newHomarrProviderFromEnv()
if err != nil {
t.Fatal(err)
}
if provider.apiKey != "test-key" || provider.baseURL != "https://router.example/v1" || provider.dailyLimit != 75 {
if provider.apiKey != "test-key" || provider.baseURL != "https://router.example/v1" ||
provider.modelID != "mock/team-selected-model" || provider.dailyLimit != 75 || provider.globalDailyLimit != 900 {
t.Fatalf("unexpected provider config: %#v", provider)
}
@@ -174,6 +150,10 @@ func TestProviderEnvironment(t *testing.T) {
t.Fatalf("expected unsafe upstream URL %q to fail", unsafeURL)
}
}
t.Setenv("HOMARR_AI_OPENROUTER_BASE_URL", "http://router.example/v1")
if _, err := newHomarrProviderFromEnv(); err == nil {
t.Fatal("expected an insecure upstream to fail without an explicit development opt-in")
}
}
func TestReadBoundedBody(t *testing.T) {
@@ -199,13 +179,3 @@ func TestCopyBounded(t *testing.T) {
t.Fatalf("expected bounded copy failure, got %q, %d, %v", target.String(), written, err)
}
}
func TestRequestBelongsToQuotaDay(t *testing.T) {
startedAt := time.Date(2026, 8, 12, 1, 30, 0, 0, time.FixedZone("CEST", 2*60*60))
if !requestBelongsToQuotaDay("2026-08-11", startedAt) {
t.Fatal("expected request to match its UTC start day")
}
if requestBelongsToQuotaDay("2026-08-12", startedAt) {
t.Fatal("request usage must not be added after the quota record resets")
}
}
@@ -1,65 +1,8 @@
/// <reference path="../pb_data/types.d.ts" />
const requestFields = () => [
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["processing", "completed", "failed"] },
{ type: "text", name: "model", required: true, min: 1, max: 128 },
{ type: "number", name: "requestUnits", required: true, onlyInt: true, min: 1, max: 1000 },
{ type: "number", name: "inputTokens", onlyInt: true, min: 0 },
{ type: "number", name: "outputTokens", onlyInt: true, min: 0 },
{ type: "number", name: "totalTokens", onlyInt: true, min: 0 },
{ type: "number", name: "durationMs", onlyInt: true, min: 0 },
{ type: "number", name: "cost", min: 0 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
];
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
const activity = new Collection({
type: "base",
name: "assistant_activity",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: requestFields(),
});
activity.addIndex("idx_assistant_activity_created", false, "created", "");
app.save(activity);
const requests = new Collection({
type: "base",
name: "assistant_requests",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
...requestFields(),
{
type: "relation",
name: "user",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{
type: "relation",
name: "publicActivity",
required: false,
maxSelect: 1,
collectionId: activity.id,
cascadeDelete: false,
},
],
});
requests.addIndex("idx_assistant_requests_user_created", false, "user, created", "");
app.save(requests);
const quotas = new Collection({
type: "base",
name: "assistant_quotas",
@@ -77,22 +20,42 @@ migrate(
collectionId: users.id,
cascadeDelete: true,
},
{ type: "number", name: "dailyLimit", required: true, onlyInt: true, min: 0, max: 100000 },
{ type: "text", name: "day", required: true, min: 10, max: 10 },
{ type: "number", name: "used", onlyInt: true, min: 0 },
{ type: "number", name: "inputTokens", onlyInt: true, min: 0 },
{ type: "number", name: "outputTokens", onlyInt: true, min: 0 },
{ type: "number", name: "totalTokens", onlyInt: true, min: 0 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
quotas.addIndex("idx_assistant_quotas_user", true, "user", "");
app.save(quotas);
const globalQuota = new Collection({
type: "base",
name: "assistant_global_quota",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "text", name: "key", required: true, min: 1, max: 32 },
{ type: "text", name: "day", required: true, min: 10, max: 10 },
{ type: "number", name: "used", onlyInt: true, min: 0 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
globalQuota.addIndex("idx_assistant_global_quota_key", true, "key", "");
app.save(globalQuota);
const globalRecord = new Record(globalQuota);
globalRecord.set("key", "default");
globalRecord.set("day", "1970-01-01");
globalRecord.set("used", 0);
app.save(globalRecord);
},
(app) => {
app.delete(app.findCollectionByNameOrId("assistant_global_quota"));
app.delete(app.findCollectionByNameOrId("assistant_quotas"));
app.delete(app.findCollectionByNameOrId("assistant_requests"));
app.delete(app.findCollectionByNameOrId("assistant_activity"));
},
);
+2
View File
@@ -4,6 +4,8 @@ set -eu
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
export HOMARR_AI_OPENROUTER_BASE_URL=http://assistant-upstream:18091/api/v1
export HOMARR_AI_OPENROUTER_MODEL=mock/team-selected-model
export HOMARR_AI_ALLOW_INSECURE_UPSTREAM=true
export OPENROUTER_API_KEY=workshop-test-openrouter-key
docker build --target pocketbase-test -f apps/workshop/Dockerfile .
+16 -4
View File
@@ -1,6 +1,7 @@
import { createServer } from "node:http";
const port = Number(process.env.PORT ?? 18091);
const expectedModel = process.env.EXPECTED_MODEL ?? "mock/team-selected-model";
const json = (response, status, body) => {
response.writeHead(status, { "content-type": "application/json" });
@@ -24,16 +25,16 @@ const server = createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (body.model !== "~deepseek/deepseek-v4-flash-latest") {
if (body.model !== expectedModel) {
json(response, 400, { error: { message: "Unexpected upstream model" } });
return;
}
if (body.max_tokens !== 32_768 || body.n !== 1 || body.parallel_tool_calls !== false) {
if (body.max_completion_tokens !== 32_768 || body.n !== 1 || body.parallel_tool_calls !== false) {
json(response, 400, { error: { message: "Unsafe generation controls reached the upstream" } });
return;
}
if (
"max_completion_tokens" in body ||
"max_tokens" in body ||
"user" in body ||
"metadata" in body ||
"extra_body" in body ||
@@ -43,11 +44,22 @@ const server = createServer(async (request, response) => {
json(response, 400, { error: { message: "Private or unbounded request fields reached the upstream" } });
return;
}
const forbiddenRoutingField = ["models", "provider", "route", "plugins", "transforms"].find((field) => field in body);
const forbiddenCostField = ["audio", "modalities", "logprobs", "top_logprobs", "prediction", "service_tier"].find(
(field) => field in body,
);
if (forbiddenCostField) {
json(response, 400, { error: { message: `Client cost field was forwarded: ${forbiddenCostField}` } });
return;
}
const forbiddenRoutingField = ["models", "route", "plugins", "transforms"].find((field) => field in body);
if (forbiddenRoutingField) {
json(response, 400, { error: { message: `Client routing field was forwarded: ${forbiddenRoutingField}` } });
return;
}
if (body.provider?.zdr !== true || body.provider?.data_collection !== "deny") {
json(response, 400, { error: { message: "Required upstream privacy controls are missing" } });
return;
}
const webSearchTools = body.tools?.filter((tool) => tool.type === "openrouter:web_search") ?? [];
if (
webSearchTools.length > 1 ||
+9 -11
View File
@@ -75,30 +75,28 @@ for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "git
}
const providerMigration = await read("apps/workshop/pb_migrations/1786500000_homarr_provider.js");
for (const required of [
'name: "assistant_quotas"',
'name: "assistant_requests"',
'name: "assistant_activity"',
'listRule: ""',
"idx_assistant_quotas_user",
]) {
for (const required of ['name: "assistant_quotas"', 'name: "assistant_global_quota"', "idx_assistant_quotas_user"]) {
if (!providerMigration.includes(required)) throw new Error(`Homarr provider migration is missing: ${required}`);
}
if (/assistant_activity[\s\S]*name: "user"/u.test(providerMigration.split("const requests")[0])) {
throw new Error("The public assistant activity collection must not contain user identity");
for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activity"', 'name: "dailyLimit"']) {
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
}
const providerBackend = await read("apps/workshop/homarr_provider.go");
for (const required of [
'"homarr/deepseek-v4-flash-latest"',
'"homarr/model"',
'apis.RequireAuth("users")',
"DefaultActivityLoggerMiddlewareId",
'os.Getenv("OPENROUTER_API_KEY")',
'os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")',
'os.Getenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT")',
'os.Getenv("HOMARR_AI_OPENROUTER_MODEL")',
'"data_collection": "deny"',
'"zdr": true',
]) {
if (!providerBackend.includes(required)) throw new Error(`Homarr provider backend is missing: ${required}`);
}
if (/logger\(\)|\.Logger\(/u.test(providerBackend)) {
if (/logger\(\)|\.Logger\(|log\.Print/u.test(providerBackend)) {
throw new Error("The Homarr provider must not log request content or user tokens");
}
for (const required of [
+67 -123
View File
@@ -1,34 +1,5 @@
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
const createSseReader = (body) => {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
return {
cancel: () => reader.cancel(),
async next() {
while (true) {
const boundary = buffer.indexOf("\n\n");
if (boundary >= 0) {
const block = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const result = { event: "message", id: "", data: "" };
for (const line of block.split("\n")) {
if (line.startsWith("event:")) result.event = line.slice(6).trim();
if (line.startsWith("id:")) result.id = line.slice(3).trim();
if (line.startsWith("data:")) result.data += line.slice(5).trim();
}
if (result.data || result.id) return result;
continue;
}
const chunk = await reader.read();
if (chunk.done) throw new Error("PocketBase realtime stream ended unexpectedly");
buffer += decoder.decode(chunk.value, { stream: true }).replaceAll("\r\n", "\n");
}
},
};
};
const request = async (path, init = {}) => {
const response = await fetch(`${baseUrl}${path}`, {
...init,
@@ -65,12 +36,11 @@ for (const required of [
"workshop_listings",
"workshop_report_summaries",
"assistant_quotas",
"assistant_requests",
"assistant_activity",
"assistant_global_quota",
]) {
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
}
for (const removed of ["workshop_admin_actions", "workshop_admins"]) {
for (const removed of ["workshop_admin_actions", "workshop_admins", "assistant_requests", "assistant_activity"]) {
if (collectionNames.has(removed)) throw new Error(`Removed Workshop collection still exists: ${removed}`);
}
@@ -136,7 +106,7 @@ const modelList = await request("/api/ai/v1/models");
if (
modelList.object !== "list" ||
modelList.data.length !== 1 ||
modelList.data[0].id !== "homarr/deepseek-v4-flash-latest" ||
modelList.data[0].id !== "homarr/model" ||
modelList.data[0].context_length !== 256 * 1024
) {
throw new Error("The Homarr provider must advertise exactly one model");
@@ -147,7 +117,7 @@ await expectStatus(
{
method: "POST",
body: JSON.stringify({
model: "homarr/deepseek-v4-flash-latest",
model: "homarr/model",
messages: [{ role: "user", content: "unauthenticated" }],
}),
},
@@ -172,7 +142,7 @@ await expectStatus(
method: "POST",
headers: authorSession.headers,
body: JSON.stringify({
model: "homarr/deepseek-v4-flash-latest",
model: "homarr/model",
messages: [
{ role: "user", content: "too many tools" },
...Array.from({ length: 1000 }, (_, index) => ({
@@ -192,8 +162,8 @@ await expectStatus(
method: "POST",
headers: authorSession.headers,
body: JSON.stringify({
model: "homarr/deepseek-v4-flash-latest",
messages: [{ role: "user", content: "x".repeat(256 * 1024) }],
model: "homarr/model",
messages: [{ role: "user", content: "x".repeat(4 * 256 * 1024 + 1) }],
}),
},
413,
@@ -213,31 +183,12 @@ await expectStatus(
400,
);
const realtimeResponse = await fetch(`${baseUrl}/api/realtime`);
if (!realtimeResponse.ok || !realtimeResponse.body) throw new Error("PocketBase realtime connection failed");
const realtime = createSseReader(realtimeResponse.body);
const connectedEvent = await realtime.next();
if (connectedEvent.event !== "PB_CONNECT" || !connectedEvent.id) {
throw new Error("PocketBase realtime did not provide a client ID");
}
await request("/api/realtime", {
method: "POST",
body: JSON.stringify({ clientId: connectedEvent.id, subscriptions: ["assistant_activity/*"] }),
});
const activityEvent = (async () => {
while (true) {
const event = await realtime.next();
const payload = JSON.parse(event.data);
if (payload.action === "create") return payload.record;
}
})();
const streamStartedAt = performance.now();
const streamResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({
model: "homarr/deepseek-v4-flash-latest",
model: "homarr/model",
messages: [{ role: "user", content: "hello" }],
stream: true,
}),
@@ -265,20 +216,11 @@ if (
) {
throw new Error("The Homarr provider did not preserve the upstream SSE stream");
}
const liveActivity = await Promise.race([
activityEvent,
new Promise((_, reject) => setTimeout(() => reject(new Error("Assistant activity realtime event timed out")), 2_000)),
]);
await realtime.cancel();
if (liveActivity.model !== "homarr/deepseek-v4-flash-latest" || liveActivity.requestUnits !== 1) {
throw new Error("The public assistant activity event is invalid");
}
const toolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({
model: "homarr/deepseek-v4-flash-latest",
model: "homarr/model",
messages: [
{ role: "user", content: "inspect my dashboard" },
{ role: "assistant", content: null, tool_calls: [{ id: "one" }, { id: "two" }] },
@@ -298,6 +240,12 @@ const toolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
extra_body: { provider: { order: ["attacker"] } },
extra_headers: { Authorization: "Bearer attacker" },
metadata: { user: "must-not-reach-openrouter" },
audio: { format: "wav", voice: "alloy" },
modalities: ["text", "audio"],
logprobs: true,
top_logprobs: 20,
prediction: { content: "attacker-controlled prediction" },
service_tier: "priority",
reasoning: { effort: "high", max_tokens: 1_000_000 },
user: "must-not-reach-openrouter",
tools: [
@@ -307,12 +255,12 @@ const toolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
],
}),
});
if (!toolResponse.ok || toolResponse.headers.get("x-homarr-quota-remaining") !== "46") {
throw new Error("Tool results must each consume one Homarr provider request unit");
if (!toolResponse.ok || toolResponse.headers.get("x-homarr-quota-remaining") !== "48") {
throw new Error("Every upstream inference must consume one Homarr provider request unit");
}
const afterToolsUsage = await request("/api/ai/usage", { headers: authorSession.headers });
if (afterToolsUsage.used !== 4 || afterToolsUsage.remaining !== 46) {
if (afterToolsUsage.used !== 2 || afterToolsUsage.remaining !== 48) {
throw new Error(`Tool request accounting is incorrect: ${JSON.stringify(afterToolsUsage)}`);
}
@@ -320,12 +268,12 @@ const upstreamFailure = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({
model: "homarr/deepseek-v4-flash-latest",
model: "homarr/model",
messages: [{ role: "user", content: "upstream failure" }],
stream: false,
}),
});
if (upstreamFailure.status !== 503 || upstreamFailure.headers.get("x-homarr-quota-remaining") !== "45") {
if (upstreamFailure.status !== 503 || upstreamFailure.headers.get("x-homarr-quota-remaining") !== "47") {
throw new Error("Every request forwarded upstream must consume Homarr provider allowance");
}
const upstreamFailureBody = await upstreamFailure.text();
@@ -333,7 +281,7 @@ if (upstreamFailureBody.includes("Simulated upstream failure")) {
throw new Error("Upstream error details must not be exposed to clients");
}
const afterFailureUsage = await request("/api/ai/usage", { headers: authorSession.headers });
if (afterFailureUsage.used !== 5 || afterFailureUsage.remaining !== 45) {
if (afterFailureUsage.used !== 3 || afterFailureUsage.remaining !== 47) {
throw new Error(`Forwarded upstream failure escaped quota accounting: ${JSON.stringify(afterFailureUsage)}`);
}
const visitorUsage = await request("/api/ai/usage", { headers: visitorSession.headers });
@@ -341,75 +289,42 @@ if (visitorUsage.used !== 0 || visitorUsage.remaining !== 50) {
throw new Error(`Provider allowance leaked across users: ${JSON.stringify(visitorUsage)}`);
}
const publicActivities = await request("/api/collections/assistant_activity/records?sort=-created&perPage=10");
if (
publicActivities.items.length < 2 ||
publicActivities.items.some((item) => "user" in item) ||
!publicActivities.items.some(
(item) => item.status === "completed" && item.inputTokens === 12 && item.outputTokens === 7,
)
) {
throw new Error("Public assistant activity must be anonymous and include completed token totals");
}
await expectStatus(
"/api/collections/assistant_activity/records",
{
method: "POST",
body: JSON.stringify({
status: "completed",
model: "forged",
requestUnits: 1,
}),
},
403,
);
await expectStatus("/api/collections/assistant_requests/records", { headers: authorSession.headers }, 403);
await expectStatus("/api/collections/assistant_quotas/records", { headers: authorSession.headers }, 403);
const privateRequests = await request("/api/collections/assistant_requests/records?perPage=20", {
headers: rootHeaders,
});
if (
privateRequests.items.length < 2 ||
privateRequests.items.some((item) => item.user !== author.id) ||
privateRequests.items.some((item) => "messages" in item || "prompt" in item || "body" in item)
) {
throw new Error("Private provider accounting must identify the user without storing request content");
}
const requestToRetain = privateRequests.items.find((item) => item.publicActivity);
if (!requestToRetain) throw new Error("Expected a private request linked to public activity");
await request(`/api/collections/assistant_activity/records/${requestToRetain.publicActivity}`, {
method: "DELETE",
headers: rootHeaders,
});
const retainedRequest = await request(`/api/collections/assistant_requests/records/${requestToRetain.id}`, {
headers: rootHeaders,
});
if (retainedRequest.id !== requestToRetain.id || retainedRequest.publicActivity) {
throw new Error("Pruning public activity must retain private accounting records");
}
await expectStatus("/api/collections/assistant_global_quota/records", { headers: authorSession.headers }, 403);
const quotas = await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders });
const authorQuota = quotas.items.find((item) => item.user === author.id);
if (!authorQuota || authorQuota.dailyLimit !== 50 || authorQuota.used !== 5 || authorQuota.totalTokens !== 38) {
if (
!authorQuota ||
authorQuota.used !== 3 ||
["dailyLimit", "inputTokens", "outputTokens", "totalTokens", "model", "cost"].some((field) => field in authorQuota)
) {
throw new Error(`Private provider quota is incorrect: ${JSON.stringify(authorQuota)}`);
}
await request(`/api/collections/assistant_quotas/records/${authorQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
body: JSON.stringify({ day: "2000-01-01", used: 49, dailyLimit: 2 }),
body: JSON.stringify({ day: "2000-01-01", used: 49 }),
});
const resetUsage = await request("/api/ai/usage", { headers: authorSession.headers });
if (resetUsage.limit !== 2 || resetUsage.used !== 0 || resetUsage.remaining !== 2) {
if (resetUsage.limit !== 50 || resetUsage.used !== 0 || resetUsage.remaining !== 50) {
throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`);
}
const resetQuota = (
await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders })
).items.find((item) => item.user === author.id);
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
body: JSON.stringify({ used: 48 }),
});
const allowanceProbe = () =>
fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({
model: "homarr/deepseek-v4-flash-latest",
model: "homarr/model",
messages: [{ role: "user", content: "allowance probe" }],
stream: false,
}),
@@ -425,6 +340,35 @@ if (
throw new Error("The per-user allowance was not enforced atomically");
}
const globalQuota = (await request("/api/collections/assistant_global_quota/records", { headers: rootHeaders }))
.items[0];
await request(`/api/collections/assistant_global_quota/records/${globalQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
body: JSON.stringify({ day: new Date().toISOString().slice(0, 10), used: 9_999 }),
});
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
body: JSON.stringify({ used: 0 }),
});
const globalResponses = await Promise.all([
allowanceProbe(),
fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...visitorSession.headers },
body: JSON.stringify({ model: "homarr/model", messages: [{ role: "user", content: "global allowance probe" }] }),
}),
]);
if (
globalResponses
.map((response) => response.status)
.toSorted()
.join(",") !== "200,429"
) {
throw new Error("The shared upstream allowance was not enforced atomically across users");
}
const widget = {
$schema: "homarr-custom-widget-v2",
name: "Workshop runtime probe",
+2 -8
View File
@@ -80,19 +80,13 @@ describe.skipIf(process.env.UPDATE_DOCS_SCREENSHOTS !== "true")("Assistant docum
baseUrl: "https://homarr.dev/api/ai/v1",
modelDiscoveryPath: "/models",
encryptedApiKey: null,
modelId: "~deepseek/deepseek-v4-flash-latest",
modelId: "homarr/model",
});
await page.goto(`${baseUrl}/manage/assistant`);
await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible();
await expect(page.getByText("Homarr", { exact: true }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh models" })).toBeVisible({ timeout: 15_000 });
const mainBox = await page.getByRole("main").first().boundingBox();
if (!mainBox) throw new Error("Assistant management page has no visible main content");
await page.screenshot({
animations: "disabled",
path: path.join(outputDirectory, "homarr-provider.png"),
clip: { ...mainBox, height: Math.min(mainBox.height, 1_090) },
});
await expect(page.getByText("Homarr model")).toBeVisible();
} finally {
await browser.close();
await homarrContainer.stop();
+7 -2
View File
@@ -29,6 +29,7 @@ import {
import type { createTRPCContext } from "../trpc";
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
import { env } from "../env";
import { orderMessagesByParent } from "../assistant-message-order";
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
@@ -129,6 +130,9 @@ const normalizeDiscoveryPath = (value: string | null) => {
return path;
};
const getHomarrProviderBaseUrl = () =>
`${(env.WORKSHOP_API_URL ?? env.HOMARR_WEBSITE_URL).replace(/\/+$/u, "")}/api/ai/v1`;
const getConfigurationAsync = async (db: Database) => {
return await db.query.assistantConfigurations.findFirst({
where: eq(assistantConfigurations.id, configurationId),
@@ -547,8 +551,9 @@ export const assistantRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const baseUrl = normalizeBaseUrl(input.baseUrl);
const modelDiscoveryPath = normalizeDiscoveryPath(input.modelDiscoveryPath);
const baseUrl = input.provider === "homarr" ? getHomarrProviderBaseUrl() : normalizeBaseUrl(input.baseUrl);
const modelDiscoveryPath =
input.provider === "homarr" ? "/models" : normalizeDiscoveryPath(input.modelDiscoveryPath);
const existing = await getConfigurationAsync(ctx.db);
const destinationChanged =
existing !== undefined && (existing.provider !== input.provider || existing.baseUrl !== baseUrl);
@@ -128,8 +128,8 @@ describe("assistantRouter.updateConnection", () => {
await caller.updateConnection({
provider: "homarr",
baseUrl: "https://homarr.dev/api/ai/v1",
modelDiscoveryPath: "/models",
baseUrl: "https://attacker.example/v1",
modelDiscoveryPath: "/attacker-models",
apiKey: "must-not-be-stored",
customHeaders: { "X-Secret": "must-not-be-stored" },
clearApiKey: false,
@@ -139,6 +139,8 @@ describe("assistantRouter.updateConnection", () => {
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration).toMatchObject({
provider: "homarr",
baseUrl: "https://homarr.dev/api/ai/v1",
modelDiscoveryPath: "/models",
encryptedApiKey: null,
encryptedHeaders: null,
enabled: false,
@@ -203,7 +205,7 @@ describe("assistantRouter.updateConfiguration", () => {
baseUrl: "https://homarr.dev/api/ai/v1",
encryptedApiKey: null,
encryptedHeaders: null,
modelId: "homarr/deepseek-v4-flash-latest",
modelId: "homarr/model",
})
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
@@ -213,8 +215,8 @@ describe("assistantRouter.updateConfiguration", () => {
Response.json({
data: [
{
id: "homarr/deepseek-v4-flash-latest",
name: "DeepSeek V4 Flash Latest",
id: "homarr/model",
name: "Homarr model",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
@@ -226,7 +228,7 @@ describe("assistantRouter.updateConfiguration", () => {
try {
await caller.updateConfiguration({
enabled: true,
modelId: "homarr/deepseek-v4-flash-latest",
modelId: "homarr/model",
webSearchEnabled: true,
});
} finally {
@@ -23,7 +23,7 @@ export const resolveAiEvaluationProviderConfig = (environment: Record<string, st
const baseUrl = (configuredBaseUrl || DEFAULT_AI_PROVIDER_BASE_URL).replace(/\/+$/u, "");
const homarrProvider = baseUrl.endsWith("/api/ai/v1");
const openRouterProvider = baseUrl === DEFAULT_AI_PROVIDER_BASE_URL;
const providerDefaultModel = homarrProvider ? "homarr/deepseek-v4-flash-latest" : DEFAULT_GENERATOR_MODEL;
const providerDefaultModel = homarrProvider ? "homarr/model" : DEFAULT_GENERATOR_MODEL;
return {
apiKey: environment.AI_PROVIDER_API_KEY ?? (openRouterProvider ? environment.OPENROUTER_API_KEY : undefined),
baseUrl,
@@ -194,8 +194,8 @@ describe("AI authoring evaluation", () => {
).toEqual({
apiKey: "workshop-token",
baseUrl: "https://homarr.dev/api/ai/v1",
generatorModel: "homarr/deepseek-v4-flash-latest",
judgeModel: "homarr/deepseek-v4-flash-latest",
generatorModel: "homarr/model",
judgeModel: "homarr/model",
});
expect(
resolveAiEvaluationProviderConfig({
@@ -221,8 +221,8 @@ describe("AI authoring evaluation", () => {
).toEqual({
apiKey: "workshop-token",
baseUrl: "https://homarr.dev/api/ai/v1",
generatorModel: "homarr/deepseek-v4-flash-latest",
judgeModel: "homarr/deepseek-v4-flash-latest",
generatorModel: "homarr/model",
judgeModel: "homarr/model",
});
});
+1
View File
@@ -46,6 +46,7 @@ interface AssistantProviderPreset {
export const assistantProviderPresets = {
homarr: {
// Browser and server code replace this public fallback with WORKSHOP_API_URL at runtime.
baseUrl: "https://homarr.dev/api/ai/v1",
modelDiscoveryPath: "/models",
requiresApiKey: false,
+1 -1
View File
@@ -6212,7 +6212,7 @@
"options": {
"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."
"description": "Community-funded access to the model selected by the Homarr team. Each user signs in with the Community Workshop and receives a daily allowance."
},
"openrouter": {
"label": "OpenRouter",
+5 -35
View File
@@ -13,7 +13,6 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type {
WorkshopComment,
WorkshopAssistantActivity,
WorkshopAssistantUsage,
WorkshopReport,
WorkshopReportSummary,
@@ -30,7 +29,6 @@ import {
WORKSHOP_REQUEST_TIMEOUT_MS,
WORKSHOP_SCHEMA_BY_TYPE,
workshopCommentSchema,
workshopAssistantActivitySchema,
workshopAssistantUsageSchema,
workshopReportSchema,
workshopReportSummarySchema,
@@ -144,19 +142,6 @@ 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>;
@@ -165,7 +150,6 @@ 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;
}
@@ -289,6 +273,7 @@ export class WorkshopBackend {
}
public get currentUser(): WorkshopUser | null {
if (!this.pocketBase.authStore.isValid) return null;
const record = this.pocketBase.authStore.record;
if (!record) return null;
const parsed = workshopUserSchema.safeParse({
@@ -308,7 +293,10 @@ export class WorkshopBackend {
}
public async refreshAuth() {
if (!this.pocketBase.authStore.isValid) return null;
if (!this.pocketBase.authStore.isValid) {
this.pocketBase.authStore.clear();
return null;
}
try {
await this.pocketBase.collection("users").authRefresh();
return this.currentUser;
@@ -352,24 +340,6 @@ export class WorkshopBackend {
}
}
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;
+2 -30
View File
@@ -88,7 +88,7 @@ describe("WorkshopBackend", () => {
test("lets PocketBase own the complete GitHub popup flow", async () => {
mocks.authWithOAuth2.mockResolvedValue({
token: "token",
token: validWorkshopToken,
record: {
id: "user-id",
name: "octocat",
@@ -137,6 +137,7 @@ describe("WorkshopBackend", () => {
id: "author-id",
name: "octocat",
} as never);
expect(client.currentUser).toBeNull();
expect(client.authToken).toBeNull();
client.pocketBase.authStore.save(validWorkshopToken, { id: 42 } as never);
@@ -144,35 +145,6 @@ describe("WorkshopBackend", () => {
expect(client.authToken).toBeNull();
});
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(validWorkshopToken, { id: "author-id" } as never);
-15
View File
@@ -110,21 +110,6 @@ export const workshopAssistantUsageSchema = z.object({
});
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>;
-3
View File
@@ -1198,9 +1198,6 @@ importers:
jszip:
specifier: 'catalog:'
version: 3.10.1
lucide-react:
specifier: 'catalog:'
version: 1.24.0(react@19.2.7)
mantine-datatable:
specifier: 'catalog:'
version: 9.3.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)