fix: address provider review feedback
This commit is contained in:
1 parent
bffa3e723c
commit
c16e00f94c
15 files changed
+154
-95
No files matched your search
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function getRndInteger(min: number, max: number) {
|
||||
return Math.round(Math.random() * (max - min)) + min;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)},
|
||||
}},
|
||||
}},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Generated
-8
@@ -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
|
||||
|
||||
Reference in new issue
Block a user