Address CodeRabbit provider feedback
This commit is contained in:
1 parent
ea9075bde5
commit
abaf845e94
19 files changed
+293
-64
No files matched your search
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user