diff --git a/apps/docs/src/components/assistant-provider/assistant-provider-activity.module.css b/apps/docs/src/components/assistant-provider/assistant-provider-activity.module.css index b94ef90a4..6b61c2f26 100644 --- a/apps/docs/src/components/assistant-provider/assistant-provider-activity.module.css +++ b/apps/docs/src/components/assistant-provider/assistant-provider-activity.module.css @@ -34,6 +34,10 @@ 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; @@ -148,7 +152,7 @@ } @media (prefers-reduced-motion: reduce) { - .beep { + .live[data-connected] .beep { animation: none; } } diff --git a/apps/docs/src/components/assistant-provider/assistant-provider-activity.tsx b/apps/docs/src/components/assistant-provider/assistant-provider-activity.tsx index 980ed9821..45a91d094 100644 --- a/apps/docs/src/components/assistant-provider/assistant-provider-activity.tsx +++ b/apps/docs/src/components/assistant-provider/assistant-provider-activity.tsx @@ -30,6 +30,9 @@ const mergeActivity = (current: WorkshopAssistantActivity[], next: WorkshopAssis .toSorted((left, right) => activityDate(right.created).getTime() - activityDate(left.created).getTime()) .slice(0, 10); +const mergeActivities = (current: WorkshopAssistantActivity[], next: WorkshopAssistantActivity[]) => + next.reduce(mergeActivity, current); + export const AssistantProviderActivity = ({ compact = false }: { compact?: boolean }) => { const { siteConfig } = useDocusaurusContext(); const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? ""; @@ -44,8 +47,6 @@ export const AssistantProviderActivity = ({ compact = false }: { compact?: boole const backend = getWorkshopBackend(workshopUrl); const load = async () => { try { - const initial = await backend.listAssistantActivity(10); - if (active) setActivities(initial); const nextUnsubscribe = await backend.pocketBase.collection("assistant_activity").subscribe("*", (event) => { const parsed = workshopAssistantActivitySchema.safeParse(event.record); if (!active || !parsed.success) return; @@ -60,6 +61,12 @@ export const AssistantProviderActivity = ({ compact = false }: { compact?: boole } } catch { if (active) setConnected(false); + } + try { + const initial = await backend.listAssistantActivity(10); + if (active) setActivities((current) => mergeActivities(current, initial)); + } catch { + // Realtime activity can still populate this view when the initial list request fails. } finally { if (active) setLoading(false); } @@ -77,7 +84,7 @@ export const AssistantProviderActivity = ({ compact = false }: { compact?: boole

Homarr provider activity

- + {connected ? "Live" : loading ? "Connecting" : "Offline"} diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-provider-options.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-provider-options.spec.ts index d009ca464..7e6a4602b 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-provider-options.spec.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-provider-options.spec.ts @@ -21,7 +21,9 @@ describe("toProviderOptionsKey", () => { test("leaves names without separators unchanged", () => { expect(toProviderOptionsKey("homarr")).toBe("homarr"); }); +}); +describe("resolveHomarrProviderToken", () => { test("accepts a Workshop token only for the matching Homarr provider endpoint", () => { const headers = new Headers({ [assistantHomarrProviderTokenHeader]: "workshop-token" }); expect( @@ -35,6 +37,24 @@ describe("toProviderOptionsKey", () => { expect(getHomarrProviderBaseUrl("https://homarr.dev/")).toBe("https://homarr.dev/api/ai/v1"); }); + test.each([ + [undefined, null], + ["", null], + [" ", null], + ["a".repeat(4096), "a".repeat(4096)], + ["a".repeat(4097), null], + ])("validates the Workshop token header %s", (token, expected) => { + const headers = new Headers(token === undefined ? undefined : { [assistantHomarrProviderTokenHeader]: token }); + expect( + resolveHomarrProviderToken({ + provider: "homarr", + configuredBaseUrl: "https://homarr.dev/api/ai/v1", + workshopApiUrl: "https://homarr.dev", + headers, + }), + ).toBe(expected); + }); + test("never forwards a Workshop token to another endpoint", () => { expect(() => resolveHomarrProviderToken({ diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index 7547be6bd..ce09bc9b3 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -432,11 +432,18 @@ export async function POST(request: Request) { workshopApiUrl: appEnv.WORKSHOP_API_URL ?? appEnv.HOMARR_WEBSITE_URL, headers: request.headers, }); - } catch { + } catch (error) { + logger.warn("Rejected an unsafe Homarr provider endpoint", { + provider: configuration.provider, + error, + }); return Response.json({ error: "The Homarr provider endpoint is not configured safely." }, { status: 503 }); } if (homarrProviderToken === null) { - return Response.json({ error: "Sign in to the Homarr Community Workshop to use the Homarr provider." }, { status: 401 }); + return Response.json( + { error: "Sign in to the Homarr Community Workshop to use the Homarr provider." }, + { status: 401 }, + ); } const context = createTRPCContext({ headers: request.headers, session }); diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 109c58ca9..4260be7f7 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -1232,7 +1232,7 @@ } .providerQuotaTrigger[data-level="ok"] { - color: var(--mantine-color-green-7); + color: light-dark(var(--mantine-color-green-8), var(--mantine-color-green-4)); } .providerQuotaTrigger[data-level="warning"] { @@ -1244,7 +1244,7 @@ } .providerQuotaTrigger[data-level="dead"] { - color: var(--mantine-color-red-7); + color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-4)); } .conversationContextPopover { diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 58e2592d9..0e6bb1426 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -121,7 +121,7 @@ import { useTimeAgo } from "@homarr/common"; import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions"; import type { AssistantProvider } from "@homarr/definitions"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import classes from "./assistant-panel.module.css"; import { getAssistantActivityState } from "./assistant-activity-state"; @@ -2558,8 +2558,10 @@ const providerQuotaColors = { const HomarrProviderQuota = () => { const t = useScopedI18n("common.assistant"); + const locale = useCurrentIntlLocale(); const preferences = useAssistantPreferences(); - const resetAt = useMemo(() => new Date(preferences.quota?.resetsAt ?? Date.now()), [preferences.quota?.resetsAt]); + const quota = preferences.quota; + const resetAt = useMemo(() => new Date(quota?.resetsAt ?? Date.now()), [quota?.resetsAt]); const resetRelative = useTimeAgo(resetAt, 30_000); if (preferences.provider !== "homarr") return null; @@ -2570,13 +2572,22 @@ const HomarrProviderQuota = () => { ? Math.min(100, Math.max(0, (preferences.quota.remaining / Math.max(preferences.quota.limit, 1)) * 100)) : 0; const label = preferences.providerUser - ? preferences.quota + ? quota ? t("providerQuota.remainingLabel", { - remaining: preferences.quota.remaining, - limit: preferences.quota.limit, + remaining: quota.remaining, + limit: quota.limit, }) : t("providerQuota.loading") : t("providerQuota.signInRequired"); + const resetTime = quota ? ( + + ) : null; return ( @@ -2638,17 +2649,10 @@ const HomarrProviderQuota = () => { - {t("providerQuota.reset", { relative: resetRelative })}{" "} - + {t.rich("providerQuota.reset", { + relative: resetRelative, + time: () => resetTime, + })} {t("providerQuota.toolCalls")} diff --git a/apps/nextjs/src/components/assistant/assistant-provider-quota.spec.ts b/apps/nextjs/src/components/assistant/assistant-provider-quota.spec.ts index 765683cf4..b324d706d 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider-quota.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-provider-quota.spec.ts @@ -13,17 +13,22 @@ describe("getAssistantProviderQuotaLevel", () => { [{ limit: 50, remaining: 25 }, "warning"], [{ limit: 50, remaining: 10 }, "bad"], [{ limit: 50, remaining: 0 }, "dead"], + [{ limit: 0, remaining: 0 }, "dead"], ] as const)("maps %o to %s", (usage, expected) => { expect(getAssistantProviderQuotaLevel(usage)).toBe(expected); }); +}); +describe("isAssistantProviderUnavailable", () => { test("blocks only unavailable Homarr provider sessions", () => { expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: false, remaining: undefined })).toBe(true); expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: true, remaining: 0 })).toBe(true); expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: true, remaining: undefined })).toBe(false); expect(isAssistantProviderUnavailable({ provider: "openrouter", signedIn: false, remaining: 0 })).toBe(false); }); +}); +describe("getAssistantProviderQuotaRefreshDelay", () => { test("refreshes just after the UTC reset, or immediately when it has passed", () => { const reset = "2026-08-12T00:00:00.000Z"; expect(getAssistantProviderQuotaRefreshDelay(reset, Date.parse("2026-08-11T23:59:00.000Z"))).toBe(61_000); diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index feb9aaebc..4657bccce 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -136,6 +136,7 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => { const [quota, setQuota] = useState> | null>(null); const [quotaLoading, setQuotaLoading] = useState(false); const [quotaError, setQuotaError] = useState(null); + const quotaRequestRef = useRef(null); const previousDefaultModelIdRef = useRef(undefined); const preferencesRef = useRef<{ modelId: string | null; reasoning: AssistantReasoningMode }>({ modelId: null, @@ -176,19 +177,30 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => { return token ? { [assistantHomarrProviderTokenHeader]: token } : {}; }, [data?.provider, workshopClient]); const refreshQuota = useCallback(async () => { + quotaRequestRef.current?.abort(); if (data?.provider !== "homarr" || !workshopClient.authToken) { + quotaRequestRef.current = null; setQuota(null); setQuotaError(null); + setQuotaLoading(false); return; } + const controller = new AbortController(); + quotaRequestRef.current = controller; setQuotaLoading(true); setQuotaError(null); try { - setQuota(await workshopClient.getAssistantUsage()); + const nextQuota = await workshopClient.getAssistantUsage(controller.signal); + if (quotaRequestRef.current === controller && !controller.signal.aborted) setQuota(nextQuota); } catch (error) { - setQuotaError(error instanceof Error ? error.message : "The Homarr provider allowance could not be loaded."); + if (quotaRequestRef.current === controller && !controller.signal.aborted) { + setQuotaError(error instanceof Error ? error.message : "The Homarr provider allowance could not be loaded."); + } } finally { - setQuotaLoading(false); + if (quotaRequestRef.current === controller) { + quotaRequestRef.current = null; + setQuotaLoading(false); + } } }, [data?.provider, workshopClient]); const signInToProvider = useCallback(async () => { @@ -228,10 +240,20 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => { return; } const unsubscribe = workshopClient.subscribeToAuth(setProviderUser); - void workshopClient.refreshAuth().then(setProviderUser); + void workshopClient + .refreshAuth() + .then(setProviderUser) + .catch(() => undefined); return unsubscribe; }, [data?.provider, workshopClient]); + useEffect( + () => () => { + quotaRequestRef.current?.abort(); + }, + [], + ); + useEffect(() => { if (data?.provider !== "homarr" || !providerUser) return; void refreshQuota(); @@ -419,7 +441,7 @@ const AssistantThreadRuntime = () => { const localThreadId = useAuiState((state) => state.threadListItem.id); const threadId = useAuiState((state) => state.threadListItem.remoteId); const preferences = useAssistantPreferences(); - const { getRequestBody, getRequestHeaders } = preferences; + const { getRequestBody, getRequestHeaders, refreshQuota } = preferences; const transport = useMemo( () => new AssistantChatTransport({ @@ -456,19 +478,20 @@ const AssistantThreadRuntime = () => { [t, threadId], ); const onError = useCallback(() => { - void preferences.refreshQuota(); + void refreshQuota(); showErrorNotification({ title: t("responseError.title"), message: t("responseError.description"), autoClose: 10_000, }); - }, [preferences, t]); + }, [refreshQuota, t]); + const onFinish = useCallback(() => void refreshQuota(), [refreshQuota]); const chat = useChat({ id: localThreadId, transport, onError, - onFinish: () => void preferences.refreshQuota(), + onFinish, sendAutomaticallyWhen: shouldAutomaticallyContinueAssistant, }); diff --git a/apps/workshop/homarr_provider.go b/apps/workshop/homarr_provider.go index c062b8969..873501373 100644 --- a/apps/workshop/homarr_provider.go +++ b/apps/workshop/homarr_provider.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "io" + "log" "net/http" "net/url" "os" @@ -33,6 +34,8 @@ const ( var errProviderResponseTooLarge = errors.New("provider response is too large") +var clientControlledRoutingFields = []string{"models", "provider", "route", "plugins", "transforms"} + type homarrProvider struct { apiKey string baseURL string @@ -180,8 +183,7 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error { if err := json.Unmarshal(body, &payload); err != nil { return event.JSON(http.StatusBadRequest, openAIError("The request body is not valid OpenAI chat JSON.")) } - payload["model"] = openRouterModelID - payload["usage"] = map[string]any{"include": true} + sanitizeProviderPayload(payload) upstreamBody, err := json.Marshal(payload) if err != nil { return event.JSON(http.StatusBadRequest, openAIError("The request body could not be prepared.")) @@ -206,7 +208,7 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error { bytes.NewReader(upstreamBody), ) if err != nil { - provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt) + provider.finishActivityWithRefund(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt, requestUnits) return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be prepared.")) } upstreamRequest.Header.Set("Authorization", "Bearer "+provider.apiKey) @@ -218,10 +220,10 @@ 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) + provider.finishActivityWithRefund(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt, requestUnits) return event.JSON(http.StatusBadGateway, openAIError("The model endpoint could not be reached.")) } - defer upstreamResponse.Body.Close() + defer func() { _ = upstreamResponse.Body.Close() }() event.Response.Header().Set("Cache-Control", "no-store") event.Response.Header().Set("X-Accel-Buffering", "no") @@ -238,7 +240,8 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error { } if upstreamResponse.StatusCode < 200 || upstreamResponse.StatusCode >= 300 { errorBody, _ := io.ReadAll(io.LimitReader(upstreamResponse.Body, maxUpstreamErrorBytes)) - provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt) + provider.finishActivityWithRefund(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt, requestUnits) + event.Response.Header().Set("X-Homarr-Quota-Remaining", strconv.Itoa(min(snapshot.Limit, snapshot.Remaining+requestUnits))) return event.Blob(upstreamResponse.StatusCode, contentType, errorBody) } @@ -261,6 +264,14 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error { return event.Blob(upstreamResponse.StatusCode, contentType, responseBody) } +func sanitizeProviderPayload(payload map[string]any) { + for _, field := range clientControlledRoutingFields { + delete(payload, field) + } + payload["model"] = openRouterModelID + payload["usage"] = map[string]any{"include": true} +} + func readBoundedBody(reader io.Reader, limit int64) ([]byte, error) { body, err := io.ReadAll(io.LimitReader(reader, limit+1)) if err != nil { @@ -382,9 +393,21 @@ func (provider *homarrProvider) finishActivity( status string, usage requestUsage, startedAt time.Time, +) { + provider.finishActivityWithRefund(app, privateRequest, publicActivity, status, usage, startedAt, 0) +} + +func (provider *homarrProvider) finishActivityWithRefund( + app core.App, + privateRequest *core.Record, + publicActivity *core.Record, + status string, + usage requestUsage, + startedAt time.Time, + refundUnits int, ) { durationMs := max(0, int(provider.now().UTC().Sub(startedAt).Milliseconds())) - _ = app.RunInTransaction(func(txApp core.App) error { + err := app.RunInTransaction(func(txApp core.App) error { for _, record := range []*core.Record{privateRequest, publicActivity} { record.Set("status", status) record.Set("inputTokens", usage.InputTokens) @@ -407,11 +430,17 @@ func (provider *homarrProvider) finishActivity( if !requestBelongsToQuotaDay(quota.GetString("day"), startedAt) { return nil } + if refundUnits > 0 { + quota.Set("used", max(0, quota.GetInt("used")-refundUnits)) + } 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 { @@ -462,7 +491,7 @@ func (capture *sseUsageCapture) Write(chunk []byte) (int, error) { capture.pending = capture.pending[end+1:] } if len(capture.pending) > maxUpstreamErrorBytes { - capture.pending = capture.pending[len(capture.pending)-maxUpstreamErrorBytes:] + capture.pending = nil } return len(chunk), nil } diff --git a/apps/workshop/homarr_provider_test.go b/apps/workshop/homarr_provider_test.go index cfc74653a..ea29f2558 100644 --- a/apps/workshop/homarr_provider_test.go +++ b/apps/workshop/homarr_provider_test.go @@ -1,12 +1,38 @@ package main import ( + "encoding/json" "math" "strings" "testing" "time" ) +func TestSanitizeProviderPayload(t *testing.T) { + payload := map[string]any{ + "model": homarrProviderModelID, + "models": []string{"attacker/model"}, + "provider": map[string]any{"order": []string{"attacker"}}, + "route": "fallback", + "plugins": []string{"web"}, + "transforms": []string{"middle-out"}, + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + } + sanitizeProviderPayload(payload) + for _, field := range clientControlledRoutingFields { + if _, exists := payload[field]; exists { + t.Fatalf("client-controlled routing field %q was forwarded", field) + } + } + if payload["model"] != openRouterModelID { + t.Fatalf("unexpected upstream model: %v", payload["model"]) + } + usage, err := json.Marshal(payload["usage"]) + if err != nil || string(usage) != `{"include":true}` { + t.Fatalf("unexpected usage options: %s, %v", usage, err) + } +} + func TestCountRequestUnits(t *testing.T) { tests := []struct { name string diff --git a/apps/workshop/pb_migrations/1786500000_homarr_provider.js b/apps/workshop/pb_migrations/1786500000_homarr_provider.js index 8a83df8c7..b5f55d1c3 100644 --- a/apps/workshop/pb_migrations/1786500000_homarr_provider.js +++ b/apps/workshop/pb_migrations/1786500000_homarr_provider.js @@ -50,10 +50,10 @@ migrate( { type: "relation", name: "publicActivity", - required: true, + required: false, maxSelect: 1, collectionId: activity.id, - cascadeDelete: true, + cascadeDelete: false, }, ], }); diff --git a/apps/workshop/tests/openrouter-mock.mjs b/apps/workshop/tests/openrouter-mock.mjs index c6a52eff0..4ea4074d0 100644 --- a/apps/workshop/tests/openrouter-mock.mjs +++ b/apps/workshop/tests/openrouter-mock.mjs @@ -28,12 +28,23 @@ const server = createServer(async (request, response) => { json(response, 400, { error: { message: "Unexpected upstream model" } }); return; } + const forbiddenRoutingField = ["models", "provider", "route", "plugins", "transforms"].find((field) => field in body); + if (forbiddenRoutingField) { + json(response, 400, { error: { message: `Client routing field was forwarded: ${forbiddenRoutingField}` } }); + return; + } + if (body.messages?.some((message) => message.content === "upstream failure")) { + json(response, 503, { error: { message: "Simulated upstream failure" } }); + return; + } const completion = { id: "gen-workshop-test", object: "chat.completion", model: body.model, - choices: [{ index: 0, message: { role: "assistant", content: "Hello from the Homarr provider" }, finish_reason: "stop" }], + choices: [ + { index: 0, message: { role: "assistant", content: "Hello from the Homarr provider" }, finish_reason: "stop" }, + ], usage: { prompt_tokens: 12, completion_tokens: 7, total_tokens: 19, cost: 0.00041 }, }; if (!body.stream) { diff --git a/apps/workshop/tests/workshop.integration.mjs b/apps/workshop/tests/workshop.integration.mjs index 9f47d7b1c..e1c5f8171 100644 --- a/apps/workshop/tests/workshop.integration.mjs +++ b/apps/workshop/tests/workshop.integration.mjs @@ -229,12 +229,13 @@ while (true) { if (chunk.done) break; streamText += new TextDecoder().decode(chunk.value); } -const streamFinishedAt = performance.now(); +const dataFrames = streamText.split(/\r?\n/u).filter((line) => line.startsWith("data:")); if ( firstChunk.done || !streamText.includes("Hello from ") || !streamText.includes("the Homarr provider") || - streamFinishedAt - firstChunkAt < 50 || + dataFrames.length < 3 || + dataFrames.at(-1)?.trim() !== "data: [DONE]" || firstChunkAt - streamStartedAt > 5_000 ) { throw new Error("The Homarr provider did not preserve the upstream SSE stream"); @@ -260,6 +261,11 @@ const toolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, { { role: "tool", tool_call_id: "two", content: "{}" }, ], stream: false, + models: ["attacker/model"], + provider: { order: ["attacker"] }, + route: "fallback", + plugins: ["web"], + transforms: ["middle-out"], }), }); if (!toolResponse.ok || toolResponse.headers.get("x-homarr-quota-remaining") !== "46") { @@ -271,6 +277,23 @@ if (afterToolsUsage.used !== 4 || afterToolsUsage.remaining !== 46) { throw new Error(`Tool request accounting is incorrect: ${JSON.stringify(afterToolsUsage)}`); } +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", + messages: [{ role: "user", content: "upstream failure" }], + stream: false, + }), +}); +if (upstreamFailure.status !== 503 || upstreamFailure.headers.get("x-homarr-quota-remaining") !== "46") { + throw new Error("Failed upstream requests must refund their Homarr provider allowance"); +} +const afterFailureUsage = await request("/api/ai/usage", { headers: authorSession.headers }); +if (afterFailureUsage.used !== 4 || afterFailureUsage.remaining !== 46) { + throw new Error(`Failed upstream request was charged: ${JSON.stringify(afterFailureUsage)}`); +} + const publicActivities = await request("/api/collections/assistant_activity/records?sort=-created&perPage=10"); if ( publicActivities.items.length < 2 || @@ -306,6 +329,18 @@ if ( ) { 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"); +} 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 !== 4 || authorQuota.totalTokens !== 38) { diff --git a/packages/custom-widgets/scripts/ai-evaluation.ts b/packages/custom-widgets/scripts/ai-evaluation.ts index 9ece6b004..c5be7223a 100644 --- a/packages/custom-widgets/scripts/ai-evaluation.ts +++ b/packages/custom-widgets/scripts/ai-evaluation.ts @@ -19,16 +19,21 @@ export const MAX_AI_EVALUATION_LOOPS = 10; export const getAiProviderChatCompletionsUrl = (baseUrl = DEFAULT_AI_PROVIDER_BASE_URL) => `${baseUrl.replace(/\/+$/u, "")}/chat/completions`; export const resolveAiEvaluationProviderConfig = (environment: Record) => { - const baseUrl = (environment.AI_PROVIDER_BASE_URL ?? DEFAULT_AI_PROVIDER_BASE_URL).replace(/\/+$/u, ""); + const configuredBaseUrl = environment.AI_PROVIDER_BASE_URL?.trim(); + 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; return { - apiKey: environment.AI_PROVIDER_API_KEY ?? environment.OPENROUTER_API_KEY, + apiKey: environment.AI_PROVIDER_API_KEY ?? (openRouterProvider ? environment.OPENROUTER_API_KEY : undefined), baseUrl, - generatorModel: environment.AI_PROVIDER_MODEL ?? environment.OPENROUTER_GENERATOR_MODEL ?? providerDefaultModel, + generatorModel: + environment.AI_PROVIDER_MODEL ?? + (openRouterProvider ? environment.OPENROUTER_GENERATOR_MODEL : undefined) ?? + providerDefaultModel, judgeModel: environment.AI_PROVIDER_JUDGE_MODEL ?? - environment.OPENROUTER_JUDGE_MODEL ?? + (openRouterProvider ? environment.OPENROUTER_JUDGE_MODEL : undefined) ?? (homarrProvider ? providerDefaultModel : DEFAULT_JUDGE_MODEL), }; }; diff --git a/packages/custom-widgets/src/test/ai-evaluation.spec.ts b/packages/custom-widgets/src/test/ai-evaluation.spec.ts index b4e3883e0..f5d257f08 100644 --- a/packages/custom-widgets/src/test/ai-evaluation.spec.ts +++ b/packages/custom-widgets/src/test/ai-evaluation.spec.ts @@ -5,6 +5,7 @@ import { buildEvaluationPrompt, buildJudgePrompt, buildRepairPrompt, + DEFAULT_AI_PROVIDER_BASE_URL, DEFAULT_GENERATOR_MODEL, DEFAULT_JUDGE_MODEL, getAiProviderChatCompletionsUrl, @@ -196,6 +197,33 @@ describe("AI authoring evaluation", () => { generatorModel: "homarr/deepseek-v4-flash-latest", judgeModel: "homarr/deepseek-v4-flash-latest", }); + expect( + resolveAiEvaluationProviderConfig({ + AI_PROVIDER_BASE_URL: " ", + OPENROUTER_API_KEY: "legacy-key", + OPENROUTER_GENERATOR_MODEL: "legacy-generator", + OPENROUTER_JUDGE_MODEL: "legacy-judge", + }), + ).toEqual({ + apiKey: "legacy-key", + baseUrl: DEFAULT_AI_PROVIDER_BASE_URL, + generatorModel: "legacy-generator", + judgeModel: "legacy-judge", + }); + expect( + resolveAiEvaluationProviderConfig({ + AI_PROVIDER_BASE_URL: "https://homarr.dev/api/ai/v1", + AI_PROVIDER_API_KEY: "workshop-token", + OPENROUTER_API_KEY: "must-not-leak", + OPENROUTER_GENERATOR_MODEL: "must-not-apply", + OPENROUTER_JUDGE_MODEL: "must-not-apply", + }), + ).toEqual({ + apiKey: "workshop-token", + baseUrl: "https://homarr.dev/api/ai/v1", + generatorModel: "homarr/deepseek-v4-flash-latest", + judgeModel: "homarr/deepseek-v4-flash-latest", + }); }); it("requires exceptional goal, design, practicality, and complexity scores", () => { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a97326451..c926b64c3 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2094,7 +2094,7 @@ "exhaustedDescription": "Your Homarr provider allowance is used for today. It resets at midnight UTC.", "unavailableDescription": "Sign in to the Workshop or wait for your Homarr provider allowance to reset.", "signIn": "Sign in with GitHub", - "reset": "Resets {relative} at", + "reset": "Resets {relative} at ", "toolCalls": "Each model request and completed tool call uses one request.", "retry": "Reload allowance", "level": { diff --git a/packages/workshop/src/backend.ts b/packages/workshop/src/backend.ts index aed259e70..fa04855a0 100644 --- a/packages/workshop/src/backend.ts +++ b/packages/workshop/src/backend.ts @@ -291,11 +291,12 @@ export class WorkshopBackend { public get currentUser(): WorkshopUser | null { const record = this.pocketBase.authStore.record; if (!record) return null; - return workshopUserSchema.parse({ + const parsed = workshopUserSchema.safeParse({ id: record.id, name: record.name || "", isAdmin: record.isAdmin === true, }); + return parsed.success ? parsed.data : null; } public get authToken(): string | null { @@ -326,7 +327,9 @@ export class WorkshopBackend { provider: "github", }); this.pocketBase.authStore.save(auth.token, auth.record); - return this.currentUser; + const user = this.currentUser; + if (!user) throw new Error("The Workshop returned an invalid user session."); + return user; } catch (error) { throw workshopError(error, "GitHub sign-in failed"); } @@ -351,11 +354,13 @@ export class WorkshopBackend { public async listAssistantActivity(limit = 10, signal?: AbortSignal): Promise { try { - const result = await this.pocketBase.collection("assistant_activity").getList(1, Math.min(Math.max(limit, 1), 50), { - sort: "-created", - signal: requestSignal(signal), - requestKey: null, - }); + 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] : []; diff --git a/packages/workshop/src/client.spec.ts b/packages/workshop/src/client.spec.ts index 01a8a6ae0..d52ce63d0 100644 --- a/packages/workshop/src/client.spec.ts +++ b/packages/workshop/src/client.spec.ts @@ -15,16 +15,21 @@ vi.mock("pocketbase", () => { public readonly authStore = { token: "", record: null as Record | null, - isValid: false, + get isValid() { + try { + const payload = JSON.parse(atob(this.token.split(".")[1] ?? "")) as { exp?: unknown }; + return typeof payload.exp === "number" && payload.exp > Date.now() / 1000; + } catch { + return false; + } + }, save: (token: string, record: Record) => { this.authStore.token = token; this.authStore.record = record; - this.authStore.isValid = true; }, clear: () => { this.authStore.token = ""; this.authStore.record = null; - this.authStore.isValid = false; }, }; @@ -63,6 +68,8 @@ import { WorkshopBackend } from "./backend"; import type { WorkshopSubmissionRecord } from "./backend"; import * as workshopClient from "./client"; +const validWorkshopToken = "header.eyJleHAiOjQxMDI0NDQ4MDB9.signature"; + describe("WorkshopBackend", () => { test("resolves the advertised client module", () => { expect(workshopClient.WorkshopBackend).toBe(WorkshopBackend); @@ -108,7 +115,7 @@ describe("WorkshopBackend", () => { test("reuses a valid Workshop session for the Homarr provider allowance", async () => { const client = new WorkshopBackend("https://workshop.example.com"); expect(client.authToken).toBeNull(); - client.pocketBase.authStore.save("workshop-token", { id: "author-id", name: "octocat" } as never); + client.pocketBase.authStore.save(validWorkshopToken, { id: "author-id", name: "octocat" } as never); mocks.send.mockResolvedValue({ limit: 50, used: 7, @@ -117,13 +124,26 @@ describe("WorkshopBackend", () => { }); await expect(client.getAssistantUsage()).resolves.toMatchObject({ limit: 50, remaining: 43 }); - expect(client.authToken).toBe("workshop-token"); + expect(client.authToken).toBe(validWorkshopToken); expect(mocks.send).toHaveBeenCalledWith( "/api/ai/usage", expect.objectContaining({ method: "GET", requestKey: null, signal: expect.any(AbortSignal) }), ); }); + test("fails closed for expired tokens and malformed Workshop user records", () => { + const client = new WorkshopBackend("https://workshop.example.com"); + client.pocketBase.authStore.save("header.eyJleHAiOjF9.signature", { + id: "author-id", + name: "octocat", + } as never); + expect(client.authToken).toBeNull(); + + client.pocketBase.authStore.save(validWorkshopToken, { id: 42 } as never); + expect(client.currentUser).toBeNull(); + expect(client.authToken).toBeNull(); + }); + test("loads only valid anonymous provider activity records", async () => { mocks.getList.mockResolvedValue({ items: [ @@ -155,7 +175,7 @@ describe("WorkshopBackend", () => { test("sends the exported schema while leaving initial revision ownership to PocketBase", async () => { const client = new WorkshopBackend("https://workshop.example.com"); - client.pocketBase.authStore.save("token", { id: "author-id" } as never); + client.pocketBase.authStore.save(validWorkshopToken, { id: "author-id" } as never); mocks.create.mockResolvedValue({ id: "submission-id" }); vi.spyOn(client, "get").mockResolvedValue(listingRecord() as never); diff --git a/packages/workshop/src/schema.ts b/packages/workshop/src/schema.ts index 783e574c6..d4f0f352a 100644 --- a/packages/workshop/src/schema.ts +++ b/packages/workshop/src/schema.ts @@ -106,7 +106,7 @@ export const workshopAssistantUsageSchema = z.object({ limit: z.number().int().nonnegative(), used: z.number().int().nonnegative(), remaining: z.number().int().nonnegative(), - resetsAt: z.string().datetime({ offset: true }), + resetsAt: z.iso.datetime({ offset: true }), }); export type WorkshopAssistantUsage = z.infer;