feat: add the community-funded Homarr assistant provider (#6563)

* Add Homarr provider proxy foundation

* Complete Homarr provider integration

* Harden Homarr provider integration

* Address Homarr provider review feedback

* Address CodeRabbit provider feedback

* Localize provider errors

* Handle provider activity load failures

* docs: improve workshop and homepage

* Harden Homarr provider limits

* fix: harden the Homarr provider

* fix: address provider review feedback

* fix: clarify provider request accounting

* feat: complete the Homarr provider endpoint

* fix: terminate invalid provider streams
This commit is contained in:
Thomas authored and ajnart committed 2026-08-14 11:32:11 +02:00
1 parent b8f360c04e
commit 45d8bc9443
77 files changed
+4035 -763

No files matched your search

+29 -3
View File
@@ -13,6 +13,7 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type {
WorkshopComment,
WorkshopAssistantUsage,
WorkshopReport,
WorkshopReportSummary,
WorkshopSubmissionDetail,
@@ -28,6 +29,7 @@ import {
WORKSHOP_REQUEST_TIMEOUT_MS,
WORKSHOP_SCHEMA_BY_TYPE,
workshopCommentSchema,
workshopAssistantUsageSchema,
workshopReportSchema,
workshopReportSummarySchema,
workshopSubmissionDetailSchema,
@@ -271,13 +273,19 @@ export class WorkshopBackend {
}
public get currentUser(): WorkshopUser | null {
if (!this.pocketBase.authStore.isValid) return 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 {
return this.pocketBase.authStore.isValid && this.currentUser ? this.pocketBase.authStore.token : null;
}
public subscribeToAuth(listener: (user: WorkshopUser | null) => void) {
@@ -285,7 +293,10 @@ export class WorkshopBackend {
}
public async refreshAuth() {
if (!this.pocketBase.authStore.isValid) return null;
if (!this.pocketBase.authStore.isValid) {
this.pocketBase.authStore.clear();
return null;
}
try {
await this.pocketBase.collection("users").authRefresh();
return this.currentUser;
@@ -304,7 +315,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");
}
@@ -314,6 +327,19 @@ export class WorkshopBackend {
this.pocketBase.authStore.clear();
}
public async getAssistantUsage(signal?: AbortSignal): Promise<WorkshopAssistantUsage> {
try {
const value = await this.pocketBase.send("/api/ai/usage", {
method: "GET",
signal: requestSignal(signal),
requestKey: null,
});
return workshopAssistantUsageSchema.parse(value);
} catch (error) {
throw workshopError(error, "Failed to load the Homarr provider allowance");
}
}
public async list(options: WorkshopListOptions = {}): Promise<WorkshopPage<WorkshopSubmissionSummary>> {
try {
const page = options.page ?? 1;
+58 -4
View File
@@ -6,17 +6,31 @@ const mocks = vi.hoisted(() => ({
create: vi.fn(),
getFullList: vi.fn(),
getList: vi.fn(),
send: vi.fn(),
update: vi.fn(),
}));
vi.mock("pocketbase", () => {
class MockPocketBase {
public readonly authStore = {
token: "",
record: null as Record<string, unknown> | null,
isValid: false,
save: (_token: string, record: Record<string, unknown>) => {
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;
},
clear: () => {
this.authStore.token = "";
this.authStore.record = null;
},
};
public collection() {
@@ -28,6 +42,10 @@ vi.mock("pocketbase", () => {
return this;
}
public send(path: string, options: unknown) {
return mocks.send(path, options);
}
public filter(template: string, values: Record<string, string>) {
return template.replaceAll(/\{:(\w+)\}/gu, (_match, key: string) => `'${values[key]}'`);
}
@@ -50,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);
@@ -62,12 +82,13 @@ describe("WorkshopBackend", () => {
mocks.create.mockReset();
mocks.getFullList.mockReset();
mocks.getList.mockReset();
mocks.send.mockReset();
mocks.update.mockReset();
});
test("lets PocketBase own the complete GitHub popup flow", async () => {
mocks.authWithOAuth2.mockResolvedValue({
token: "token",
token: validWorkshopToken,
record: {
id: "user-id",
name: "octocat",
@@ -91,9 +112,42 @@ describe("WorkshopBackend", () => {
expect(mocks.autoCancellation).toHaveBeenCalledWith(false);
});
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(validWorkshopToken, { id: "author-id", name: "octocat" } as never);
mocks.send.mockResolvedValue({
limit: 50,
used: 7,
remaining: 43,
resetsAt: "2026-08-12T00:00:00Z",
});
await expect(client.getAssistantUsage()).resolves.toMatchObject({ limit: 50, remaining: 43 });
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.currentUser).toBeNull();
expect(client.authToken).toBeNull();
client.pocketBase.authStore.save(validWorkshopToken, { id: 42 } as never);
expect(client.currentUser).toBeNull();
expect(client.authToken).toBeNull();
});
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);
+8
View File
@@ -102,6 +102,14 @@ export const workshopUserSchema = z.object({
});
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
export const workshopAssistantUsageSchema = z.object({
limit: z.number().int().nonnegative(),
used: z.number().int().nonnegative(),
remaining: z.number().int().nonnegative(),
resetsAt: z.iso.datetime({ offset: true }),
});
export type WorkshopAssistantUsage = z.infer<typeof workshopAssistantUsageSchema>;
const normalizeWorkshopSubmissionRecord = (value: unknown) => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
const record = value as Record<string, unknown>;