Address CodeRabbit provider feedback
This commit is contained in:
1 parent
ea9075bde5
commit
abaf845e94
19 files changed
+293
-64
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<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} aria-live="polite">
|
||||
<span className={styles.live} data-connected={connected || undefined} aria-live="polite">
|
||||
<span className={styles.beep} aria-hidden />
|
||||
{connected ? "Live" : loading ? "Connecting" : "Offline"}
|
||||
</span>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? (
|
||||
<time dateTime={quota.resetsAt} title={new Date(quota.resetsAt).toLocaleString(locale)}>
|
||||
{new Date(quota.resetsAt).toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
})}
|
||||
</time>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Popover position="top" width={290} shadow="md" withinPortal>
|
||||
@@ -2638,17 +2649,10 @@ const HomarrProviderQuota = () => {
|
||||
<Progress value={percentage} color={color} size="sm" aria-label={label} />
|
||||
</Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("providerQuota.reset", { relative: resetRelative })}{" "}
|
||||
<time
|
||||
dateTime={preferences.quota.resetsAt}
|
||||
title={new Date(preferences.quota.resetsAt).toLocaleString()}
|
||||
>
|
||||
{new Date(preferences.quota.resetsAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
})}
|
||||
</time>
|
||||
{t.rich("providerQuota.reset", {
|
||||
relative: resetRelative,
|
||||
time: () => resetTime,
|
||||
})}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("providerQuota.toolCalls")}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -136,6 +136,7 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
|
||||
const [quota, setQuota] = useState<Awaited<ReturnType<typeof workshopClient.getAssistantUsage>> | null>(null);
|
||||
const [quotaLoading, setQuotaLoading] = useState(false);
|
||||
const [quotaError, setQuotaError] = useState<string | null>(null);
|
||||
const quotaRequestRef = useRef<AbortController | null>(null);
|
||||
const previousDefaultModelIdRef = useRef<string | null | undefined>(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<AssistantUIMessage>({
|
||||
id: localThreadId,
|
||||
transport,
|
||||
onError,
|
||||
onFinish: () => void preferences.refreshQuota(),
|
||||
onFinish,
|
||||
sendAutomaticallyWhen: shouldAutomaticallyContinueAssistant,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,10 +50,10 @@ migrate(
|
||||
{
|
||||
type: "relation",
|
||||
name: "publicActivity",
|
||||
required: true,
|
||||
required: false,
|
||||
maxSelect: 1,
|
||||
collectionId: activity.id,
|
||||
cascadeDelete: true,
|
||||
cascadeDelete: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, string | undefined>) => {
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 <time></time>",
|
||||
"toolCalls": "Each model request and completed tool call uses one request.",
|
||||
"retry": "Reload allowance",
|
||||
"level": {
|
||||
|
||||
@@ -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<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,
|
||||
});
|
||||
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] : [];
|
||||
|
||||
@@ -15,16 +15,21 @@ vi.mock("pocketbase", () => {
|
||||
public readonly authStore = {
|
||||
token: "",
|
||||
record: null as Record<string, unknown> | 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<string, unknown>) => {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<typeof workshopAssistantUsageSchema>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user