fix: harden the Homarr provider
This commit is contained in:
1 parent
0774b02878
commit
bffa3e723c
43 files changed
+515
-999
No files matched your search
@@ -22,6 +22,8 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
|
||||
|
||||
## Exporting a Backup
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
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).
|
||||
@@ -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
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -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 = {
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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, "aErr) {
|
||||
@@ -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 "aExceededError{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,
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
},
|
||||
);
|
||||
@@ -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 .
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Generated
-3
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user