fix: address provider review feedback

This commit is contained in:
ajnart committed 2026-08-12 12:51:15 +02:00
1 parent bffa3e723c
commit c16e00f94c
15 files changed
+154 -95

No files matched your search

+6 -3
View File
@@ -50,7 +50,8 @@ Homarr includes presets for OpenRouter, OpenAI, Claude, Gemini, xAI, Groq, Mistr
LM Studio. Use **Custom endpoint** for another OpenAI-compatible Chat Completions service.
The [Homarr provider](../workshop/homarr-provider) does not require an administrator API key. Each user signs in to the
Community Workshop and receives a separate daily allowance.
Community Workshop and receives a separate daily allowance. A shared Workshop daily quota also applies, so requests
can be rejected after that shared quota is exhausted.
:::info Local model servers
@@ -78,5 +79,7 @@ and delete conversations.
- **A model stops during a tool call:** use a model that supports streaming and structured tool calls.
- **A local endpoint cannot be reached:** check container networking and the endpoint's listen address.
Homarr exposes the same tool set to external clients through the [MCP server](./mcp). Custom Widget authors can find
the full workflow in [Agent authoring](./custom-widgets/agent-authoring).
Homarr exposes its MCP-enabled procedures to external clients through the [MCP server](./mcp). MCP omits
`customWidget_*` tools for non-admin API keys, while the built-in assistant adds browser actions and optional
OpenRouter web search. Custom Widget authors can find the full workflow in
[Agent authoring](./custom-widgets/agent-authoring).
+2 -1
View File
@@ -41,7 +41,8 @@ WORKSHOP_API_URL=http://127.0.0.1:8090
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
```
Restart `pnpm dev` after changing `.env`. GitHub OAuth is optional locally. See
Restart `pnpm dev:docs` after changing the Docusaurus URL variables. If Homarr is also running, restart its `pnpm dev`
process after changing the Workshop URLs. GitHub OAuth is optional locally. See
[`apps/workshop/README.md`](https://github.com/homarr-labs/homarr/blob/HEAD/apps/workshop/README.md) for OAuth,
Workshop moderator promotion, logs, tests, backup, restore, stopping, and intentional volume reset.
-1
View File
@@ -51,7 +51,6 @@
"postcss": "^8.5.16",
"posthog-docusaurus": "^2.0.5",
"prism-react-renderer": "^2.4.1",
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-day-picker": "catalog:",
"react-dom": "catalog:",
@@ -1,8 +1,10 @@
import Translate from "@docusaurus/Translate";
import styles from "./open-collective-link.module.css";
export const OpenCollectiveLink = () => (
<a className={styles.link} href="https://opencollective.com/homarr">
<img className={styles.logo} src="/img/opencollective.svg" alt="" aria-hidden />
Donate to Homarr on OpenCollective
<Translate id="workshop.provider.donate">Donate to Homarr on OpenCollective</Translate>
</a>
);
@@ -1,3 +1,4 @@
import { translate } from "@docusaurus/Translate";
import { IconBrandDocker, IconCpu, IconDeviceDesktopAnalytics } from "@tabler/icons-react";
import styles from "../../../../pages/index.module.css";
@@ -30,18 +31,31 @@ export const HeroCards = () => {
);
};
const heroLabels = {
systemResources: translate({ id: "homepage.preview.systemResources", message: "System resources" }),
online: translate({ id: "homepage.preview.online", message: "Online" }),
cpu: translate({ id: "homepage.preview.cpu", message: "CPU" }),
memory: translate({ id: "homepage.preview.memory", message: "Memory" }),
disk: translate({ id: "homepage.preview.disk", message: "Disk" }),
docker: translate({ id: "homepage.preview.docker", message: "Docker" }),
dockerSummary: translate({ id: "homepage.preview.dockerSummary", message: "2 running · 1 failed" }),
healthy: translate({ id: "homepage.preview.healthy", message: "Healthy" }),
running: translate({ id: "homepage.preview.running", message: "Running" }),
exited: translate({ id: "homepage.preview.exited", message: "Exited" }),
};
const SystemResourcesWidget = () => (
<WidgetCard width={2} className={styles.resourcesWidget}>
<div className={styles.widgetHeading}>
<span className={styles.widgetTitle}>
<IconDeviceDesktopAnalytics size={17} aria-hidden="true" />
System resources
{heroLabels.systemResources}
</span>
<span className={styles.onlineStatus}>Online</span>
<span className={styles.onlineStatus}>{heroLabels.online}</span>
</div>
<ResourceMeter icon={<IconCpu size={15} aria-hidden="true" />} label="CPU" value={34} />
<ResourceMeter label="Memory" value={62} />
<ResourceMeter label="Disk" value={98} />
<ResourceMeter icon={<IconCpu size={15} aria-hidden="true" />} label={heroLabels.cpu} value={34} />
<ResourceMeter label={heroLabels.memory} value={62} />
<ResourceMeter label={heroLabels.disk} value={98} />
</WidgetCard>
);
@@ -63,23 +77,20 @@ const DockerWidget = () => (
<div className={styles.widgetHeading}>
<span className={styles.widgetTitle}>
<IconBrandDocker size={18} aria-hidden="true" />
Docker
{heroLabels.docker}
</span>
<span className={styles.dockerCount}>2 running · 1 failed</span>
<span className={styles.dockerCount}>{heroLabels.dockerSummary}</span>
</div>
<div className={styles.containerList}>
{[
["immich", "Healthy"],
["jellyfin", "Running"],
["paperless", "Exited"],
].map(([name, status]) => (
<div className={`${styles.containerRow} ${status === "Exited" ? styles.containerRowFailed : ""}`} key={name}>
{ name: "immich", status: heroLabels.healthy, failed: false },
{ name: "jellyfin", status: heroLabels.running, failed: false },
{ name: "paperless", status: heroLabels.exited, failed: true },
].map(({ name, status, failed }) => (
<div className={`${styles.containerRow} ${failed ? styles.containerRowFailed : ""}`} key={name}>
<span className={styles.containerName}>{name}</span>
<span className={styles.containerStatus}>
<span
className={`${styles.statusDot} ${status === "Exited" ? styles.statusDotFailed : ""}`}
aria-hidden="true"
/>
<span className={`${styles.statusDot} ${failed ? styles.statusDotFailed : ""}`} aria-hidden="true" />
{status}
</span>
</div>
@@ -1,4 +1,5 @@
import Link from "@docusaurus/Link";
import { translate } from "@docusaurus/Translate";
import { IconDownload, IconExternalLink } from "@tabler/icons-react";
import clsx from "clsx";
import styles from "../../../../pages/index.module.css";
@@ -57,7 +58,10 @@ export default function HomeHero() {
</Link>
</div>
</div>
<section className={clsx(styles.heroPreview, "hover-animation")} aria-label="Example Homarr dashboard">
<section
className={clsx(styles.heroPreview, "hover-animation")}
aria-label={translate({ id: "homepage.preview.label", message: "Example Homarr dashboard" })}
>
<HeroCards />
</section>
</div>
@@ -14,7 +14,7 @@ export const AppWidget = ({ name, label, className }: AppWidgetProps) => {
return (
<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`} />
<img src={app.iconUrl} className="aspect-square scale-[0.6]" alt="" />
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2" aria-hidden="true" />
</WidgetCard>
);
@@ -1,10 +1,15 @@
import { getRndInteger } from "@site/src/tools/math";
import { translate } from "@docusaurus/Translate";
import { useEffect, useState } from "react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
import { generateSlug } from "random-word-slugs";
import styles from "../../../../../pages/index.module.css";
const downloadQueue = ["family-photos.zip", "linux-isos.mkv", "server-backup.zip", "holiday-video.mp4"];
const downloadLabels = {
file: translate({ id: "homepage.preview.file", message: "File" }),
progress: translate({ id: "homepage.preview.progress", message: "Progress" }),
};
export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
const [downloads, setDownloads] = useState<Download[]>([
{ filename: "photos-backup.zip", progress: 72 },
@@ -18,11 +23,15 @@ export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
const progressedDownloads = filteredPrev.map((download, index) => ({
...download,
progress: Math.min(100, download.progress + getRndInteger(0, index < 2 ? 16 : 8)),
progress: Math.min(100, download.progress + (index < 2 ? 8 : 4)),
}));
if (progressedDownloads.length < 3 && progressedDownloads.length === prev.length) {
progressedDownloads.push(generateRandomDownload());
const availableDownloads = downloadQueue.filter(
(filename) => !progressedDownloads.some((download) => download.filename === filename),
);
const filename = availableDownloads[0];
if (filename) progressedDownloads.push({ filename, progress: 0 });
}
return progressedDownloads;
@@ -39,8 +48,8 @@ export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
<table className="mb-0 w-full h-full text-xs">
<thead className="inline-block w-full">
<tr className="inline-block w-full">
<th className="border-none px-2 py-1 text-start inline-block w-1/2">File</th>
<th className="border-none px-2 py-1 text-start inline-block w-1/2">Progress</th>
<th className="border-none px-2 py-1 text-start inline-block w-1/2">{downloadLabels.file}</th>
<th className="border-none px-2 py-1 text-start inline-block w-1/2">{downloadLabels.progress}</th>
</tr>
</thead>
<tbody>
@@ -48,12 +57,21 @@ export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
return (
<tr key={file.filename}>
<td className="border-none text-nowrap p-2">{file.filename}</td>
<td className="border-none p-2 w-full" aria-label={`${file.progress}% complete`}>
<td
className="border-none p-2 w-full"
aria-label={translate(
{ id: "homepage.preview.downloadComplete", message: "{progress}% complete" },
{ progress: file.progress },
)}
>
<progress
className={styles.downloadProgress}
value={file.progress}
max={100}
aria-label={`${file.filename} download progress`}
aria-label={translate(
{ id: "homepage.preview.downloadProgress", message: "{filename} download progress" },
{ filename: file.filename },
)}
/>
</td>
</tr>
@@ -69,15 +87,3 @@ interface Download {
filename: string;
progress: number;
}
const formats = ["mkv", "mp4"];
const generateRandomDownload = (): Download => {
const randomMovie = generateSlug(2);
const randomFormat = formats[getRndInteger(0, formats.length - 1)];
return {
filename: `${randomMovie}.${randomFormat}`,
progress: 0,
};
};
+1 -1
View File
@@ -1,4 +1,4 @@
@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 "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);
+4 -4
View File
@@ -77,7 +77,7 @@ section {
flex: none;
min-width: 0;
border-radius: 0.75rem;
border: 1px solid color-mix(in srgb, currentColor 12%, transparent) !important;
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;
@@ -154,7 +154,7 @@ section {
height: 0.375rem;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, currentColor 16%, transparent);
background: color-mix(in srgb, currentcolor 16%, transparent);
}
.resourceTrack::-webkit-progress-bar {
@@ -181,7 +181,7 @@ section {
height: 0.5rem;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, currentColor 18%, transparent);
background: color-mix(in srgb, currentcolor 18%, transparent);
}
.downloadProgress::-webkit-progress-bar {
@@ -215,7 +215,7 @@ section {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: color-mix(in srgb, currentColor 82%, transparent);
color: color-mix(in srgb, currentcolor 82%, transparent);
}
.statusDot {
-3
View File
@@ -1,3 +0,0 @@
export function getRndInteger(min: number, max: number) {
return Math.round(Math.random() * (max - min)) + min;
}
+49 -20
View File
@@ -43,7 +43,11 @@ const (
providerRequestTimeout = 5 * time.Minute
)
var errProviderResponseTooLarge = errors.New("provider response is too large")
var (
errProviderResponseTooLarge = errors.New("provider response is too large")
errInvalidTools = errors.New("invalid request tools")
errUnsupportedTool = errors.New("unsupported request tool")
)
var clientControlledRoutingFields = []string{
"models", "provider", "route", "plugins", "transforms", "extra_body", "extra_headers",
@@ -223,7 +227,11 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
return event.JSON(http.StatusRequestEntityTooLarge, openAIError(err.Error()))
}
if err := sanitizeProviderPayload(payload, provider.modelID); err != nil {
return event.JSON(http.StatusBadRequest, openAIError(err.Error()))
message := "The request contains an unsupported tool."
if errors.Is(err, errInvalidTools) {
message = "The request tools are not valid."
}
return event.JSON(http.StatusBadRequest, openAIError(message))
}
upstreamBody, err := json.Marshal(payload)
if err != nil {
@@ -277,7 +285,7 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
contentType = "application/json"
}
if upstreamResponse.StatusCode < 200 || upstreamResponse.StatusCode >= 300 {
return event.JSON(upstreamResponse.StatusCode, openAIError("The model endpoint rejected the request."))
return event.JSON(safeUpstreamStatus(upstreamResponse.StatusCode), openAIError("The model endpoint rejected the request."))
}
if requestBody.Stream {
@@ -294,6 +302,13 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
return event.Blob(upstreamResponse.StatusCode, contentType, responseBody)
}
func safeUpstreamStatus(status int) int {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return http.StatusBadGateway
}
return status
}
func sanitizeProviderPayload(payload map[string]any, upstreamModelID string) error {
for _, field := range clientControlledRoutingFields {
delete(payload, field)
@@ -312,14 +327,14 @@ func sanitizeProviderPayload(payload map[string]any, upstreamModelID string) err
if rawTools, exists := payload["tools"]; exists {
tools, ok := rawTools.([]any)
if !ok {
return errors.New("The request tools are not valid.")
return errInvalidTools
}
sanitizedTools := make([]any, 0, len(tools))
webSearchAdded := false
for _, rawTool := range tools {
tool, ok := rawTool.(map[string]any)
if !ok {
return errors.New("The request contains an unsupported tool.")
return errUnsupportedTool
}
switch tool["type"] {
case "function":
@@ -336,7 +351,7 @@ func sanitizeProviderPayload(payload map[string]any, upstreamModelID string) err
webSearchAdded = true
}
default:
return errors.New("The request contains an unsupported tool.")
return errUnsupportedTool
}
}
payload["tools"] = sanitizedTools
@@ -422,52 +437,66 @@ func (provider *homarrProvider) quota(app core.App, user *core.Record) (quotaSna
now := provider.now().UTC()
var snapshot quotaSnapshot
err := app.RunInTransaction(func(txApp core.App) error {
quota, current, err := provider.loadQuota(txApp, user, now)
quota, current, quotaChanged, err := provider.loadQuota(txApp, user, now)
if err != nil {
return err
}
globalQuota, globalRemaining, err := provider.loadGlobalQuota(txApp, now)
globalQuota, globalRemaining, globalQuotaChanged, 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
if globalQuotaChanged {
if err := txApp.Save(globalQuota); err != nil {
return err
}
}
return txApp.Save(quota)
if quotaChanged {
return txApp.Save(quota)
}
return nil
})
return snapshot, err
}
func (provider *homarrProvider) loadGlobalQuota(app core.App, now time.Time) (*core.Record, int, error) {
func (provider *homarrProvider) loadGlobalQuota(app core.App, now time.Time) (*core.Record, int, bool, error) {
quota, err := app.FindFirstRecordByFilter("assistant_global_quota", "key = 'default'")
if err != nil {
return nil, 0, err
return nil, 0, false, err
}
changed := false
if quota.GetString("day") != now.Format(time.DateOnly) {
quota.Set("day", now.Format(time.DateOnly))
quota.Set("used", 0)
changed = true
}
return quota, max(0, provider.globalDailyLimit-quota.GetInt("used")), nil
return quota, max(0, provider.globalDailyLimit-quota.GetInt("used")), changed, nil
}
func (provider *homarrProvider) loadQuota(app core.App, user *core.Record, now time.Time) (*core.Record, quotaSnapshot, error) {
func (provider *homarrProvider) loadQuota(
app core.App,
user *core.Record,
now time.Time,
) (*core.Record, quotaSnapshot, bool, error) {
quota, err := app.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, quotaSnapshot{}, err
return nil, quotaSnapshot{}, false, err
}
changed := false
if quota == nil {
collection, err := app.FindCollectionByNameOrId("assistant_quotas")
if err != nil {
return nil, quotaSnapshot{}, err
return nil, quotaSnapshot{}, false, err
}
quota = core.NewRecord(collection)
quota.Set("user", user.Id)
changed = true
}
if quota.GetString("day") != now.Format(time.DateOnly) {
quota.Set("day", now.Format(time.DateOnly))
quota.Set("used", 0)
changed = true
}
limit := provider.dailyLimit
used := quota.GetInt("used")
@@ -476,7 +505,7 @@ func (provider *homarrProvider) loadQuota(app core.App, user *core.Record, now t
Used: used,
Remaining: max(0, limit-used),
ResetsAt: time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC),
}, nil
}, changed, nil
}
func (provider *homarrProvider) startRequest(
@@ -486,11 +515,11 @@ func (provider *homarrProvider) startRequest(
var snapshot quotaSnapshot
err := app.RunInTransaction(func(txApp core.App) error {
now := provider.now().UTC()
quota, current, err := provider.loadQuota(txApp, user, now)
quota, current, _, err := provider.loadQuota(txApp, user, now)
if err != nil {
return err
}
globalQuota, globalRemaining, err := provider.loadGlobalQuota(txApp, now)
globalQuota, globalRemaining, _, err := provider.loadGlobalQuota(txApp, now)
if err != nil {
return err
}
+12 -1
View File
@@ -100,12 +100,23 @@ func TestSanitizeProviderPayloadRejectsUnsupportedServerTools(t *testing.T) {
}
}
func TestSafeUpstreamStatus(t *testing.T) {
for _, status := range []int{401, 403} {
if actual := safeUpstreamStatus(status); actual != 502 {
t.Fatalf("expected upstream %d to become 502, got %d", status, actual)
}
}
if actual := safeUpstreamStatus(429); actual != 429 {
t.Fatalf("expected upstream 429 to be preserved, got %d", actual)
}
}
func TestValidateProviderInput(t *testing.T) {
payload := map[string]any{
"messages": []any{map[string]any{
"role": "user",
"content": []any{map[string]any{
"type": "image_url",
"type": "image_url",
"image_url": map[string]any{"url": "data:image/png;base64," + strings.Repeat("a", 1_300_000)},
}},
}},
+18 -14
View File
@@ -2,6 +2,8 @@ import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, expect } from "@playwright/test";
import type { Browser } from "@playwright/test";
import type { StartedTestContainer } from "testcontainers";
import { describe, test } from "vitest";
import * as sqliteSchema from "../packages/db/schema/sqlite";
@@ -33,20 +35,23 @@ describe.skipIf(process.env.UPDATE_DOCS_SCREENSHOTS !== "true")("Assistant docum
const outputDirectory = path.resolve("apps/docs/static/img/assistant");
await mkdir(outputDirectory, { recursive: true });
const homarrContainer = await createHomarrContainer({
environment: { AUTH_PROVIDERS: "credentials" },
mounts: { "/appdata": localMountPath },
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
const browser = await chromium.launch();
const context = await browser.newContext({
colorScheme: "dark",
deviceScaleFactor: 1,
viewport: { width: 1440, height: 1000 },
});
const page = await context.newPage();
let homarrContainer: StartedTestContainer | undefined;
let browser: Browser | undefined;
try {
homarrContainer = await createHomarrContainer({
environment: { AUTH_PROVIDERS: "credentials" },
mounts: { "/appdata": localMountPath },
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
browser = await chromium.launch();
const context = await browser.newContext({
colorScheme: "dark",
deviceScaleFactor: 1,
viewport: { width: 1440, height: 1000 },
});
const page = await context.newPage();
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
@@ -88,8 +93,7 @@ describe.skipIf(process.env.UPDATE_DOCS_SCREENSHOTS !== "true")("Assistant docum
await expect(page.getByRole("button", { name: "Refresh models" })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("Homarr model")).toBeVisible();
} finally {
await browser.close();
await homarrContainer.stop();
await Promise.allSettled([browser?.close(), homarrContainer?.stop()]);
}
}, 120_000);
});
-8
View File
@@ -885,9 +885,6 @@ importers:
prism-react-renderer:
specifier: ^2.4.1
version: 2.4.1(react@19.2.7)
random-word-slugs:
specifier: ^0.1.7
version: 0.1.7
react:
specifier: 'catalog:'
version: 19.2.7
@@ -14875,9 +14872,6 @@ packages:
radix3@1.1.2:
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
random-word-slugs@0.1.7:
resolution: {integrity: sha512-8cyzxOIDeLFvwSPTgCItMXHGT5ZPkjhuFKUTww06Xg1dNMXuGxIKlARvS7upk6JXIm41ZKXmtlKR1iCRWklKmg==}
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -30727,8 +30721,6 @@ snapshots:
radix3@1.1.2: {}
random-word-slugs@0.1.7: {}
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1