feat: improve assistant background controls

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 0c5fc1d331
commit 41389b61e2
13 files changed
+644 -86

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
export {
getAssistantContextEntitiesAsync,
getAssistantModelsAsync,
getSelectedModelDetailsAsync,
type AssistantContextEntity,
} from "./router/assistant";
+100 -33
View File
@@ -34,13 +34,13 @@ const configurationId = "default";
const providerSchema = z.enum(assistantProviderIds);
const modelSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
contextLength: z.number().nullable(),
promptPrice: z.string().nullable(),
completionPrice: z.string().nullable(),
inputModalities: z.array(z.string()),
id: z.string().min(1).max(256),
name: z.string().min(1).max(256),
description: z.string().max(2048).nullable(),
contextLength: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).nullable(),
promptPrice: z.string().max(64).nullable(),
completionPrice: z.string().max(64).nullable(),
inputModalities: z.array(z.string().max(32)).max(16),
toolSupport: z.enum(["confirmed", "unknown"]),
});
@@ -71,7 +71,7 @@ export type AssistantContextEntity = {
boardId?: string;
};
const selectedModelCache = new Map<string, { expiresAt: number; value: z.infer<typeof modelSchema> | null }>();
const modelListCache = new Map<string, { expiresAt: number; value: z.infer<typeof modelSchema>[] }>();
const customHeadersSchema = z
.record(
@@ -197,7 +197,7 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
} catch {
throw new TRPCError({ code: "BAD_GATEWAY", message: "The model endpoint returned invalid JSON." });
}
const models = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
const models = (Array.isArray(body) ? body : (body.data ?? body.models ?? [])).slice(0, 1_000);
return models
.filter((model) => {
if (configuration.provider !== "openrouter") return true;
@@ -209,15 +209,21 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
})
.flatMap((model) => {
const id = typeof model.id === "string" ? model.id : typeof model.model === "string" ? model.model : null;
if (!id) return [];
if (!id || id.length > 256) return [];
const name =
typeof model.name === "string" ? model.name : typeof model.display_name === "string" ? model.display_name : id;
const contextLength =
typeof model.context_length === "number"
typeof model.context_length === "number" &&
Number.isSafeInteger(model.context_length) &&
model.context_length > 0
? model.context_length
: typeof model.max_context_length === "number"
: typeof model.max_context_length === "number" &&
Number.isSafeInteger(model.max_context_length) &&
model.max_context_length > 0
? model.max_context_length
: typeof model.max_input_tokens === "number"
: typeof model.max_input_tokens === "number" &&
Number.isSafeInteger(model.max_input_tokens) &&
model.max_input_tokens > 0
? model.max_input_tokens
: null;
const toolSupport =
@@ -231,15 +237,17 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
: Array.isArray(model.input_modalities)
? model.input_modalities
: [];
const inputModalities = rawInputModalities.filter((modality): modality is string => typeof modality === "string");
const inputModalities = rawInputModalities
.filter((modality): modality is string => typeof modality === "string" && modality.length <= 32)
.slice(0, 16);
return [
{
id,
name,
description: typeof model.description === "string" ? model.description : null,
name: name.slice(0, 256),
description: typeof model.description === "string" ? model.description.slice(0, 2048) : null,
contextLength,
promptPrice: typeof model.pricing?.prompt === "string" ? model.pricing.prompt : null,
completionPrice: typeof model.pricing?.completion === "string" ? model.pricing.completion : null,
promptPrice: typeof model.pricing?.prompt === "string" ? model.pricing.prompt.slice(0, 64) : null,
completionPrice: typeof model.pricing?.completion === "string" ? model.pricing.completion.slice(0, 64) : null,
inputModalities,
toolSupport,
},
@@ -248,27 +256,36 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
.toSorted((left, right) => left.name.localeCompare(right.name));
};
export const getSelectedModelDetailsAsync = async (configuration: AssistantConfiguration) => {
if (!configuration.modelId || !configuration.modelDiscoveryPath) return null;
const cacheKey = [
const getModelListCacheKey = (configuration: AssistantConfiguration) =>
[
configuration.provider,
configuration.baseUrl,
configuration.modelDiscoveryPath,
configuration.modelId,
configuration.updatedAt.getTime(),
].join(":");
const cached = selectedModelCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.value;
const models = await fetchModelsAsync(configuration);
const resolvedModelId = resolveAssistantModelId(models, configuration.modelId);
const value = models.find((model) => model.id === resolvedModelId) ?? null;
selectedModelCache.clear();
selectedModelCache.set(cacheKey, { expiresAt: Date.now() + 10 * 60_000, value });
export const getAssistantModelsAsync = async (configuration: AssistantConfiguration, refresh = false) => {
if (!configuration.modelDiscoveryPath) return [];
const cacheKey = getModelListCacheKey(configuration);
const cached = modelListCache.get(cacheKey);
if (!refresh && cached && cached.expiresAt > Date.now()) return cached.value;
const value = await fetchModelsAsync(configuration);
modelListCache.clear();
modelListCache.set(cacheKey, { expiresAt: Date.now() + 10 * 60_000, value });
return value;
};
export const getSelectedModelDetailsAsync = async (
configuration: AssistantConfiguration,
requestedModelId = configuration.modelId,
) => {
if (!requestedModelId || !configuration.modelDiscoveryPath) return null;
const models = await getAssistantModelsAsync(configuration);
const resolvedModelId = resolveAssistantModelId(models, requestedModelId);
return models.find((model) => model.id === resolvedModelId) ?? null;
};
export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): Promise<AssistantContextEntity[]> => {
if (!ctx.session) return [];
@@ -427,12 +444,13 @@ export const assistantRouter = createTRPCRouter({
"Returns the configured assistant model's discovered input modalities and whether image input is supported, unsupported, or unknown.",
},
})
.query(async ({ ctx }) => {
.input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional())
.query(async ({ ctx, input }) => {
const configuration = await getConfigurationAsync(ctx.db);
if (!configuration?.enabled || !configuration.modelId) {
return { imageInput: "unsupported" as const, inputModalities: [] as string[] };
}
const model = await getSelectedModelDetailsAsync(configuration).catch(() => null);
const model = await getSelectedModelDetailsAsync(configuration, input?.modelId).catch(() => null);
if (!model || model.inputModalities.length === 0) {
return { imageInput: "unknown" as const, inputModalities: [] as string[] };
}
@@ -442,6 +460,30 @@ export const assistantRouter = createTRPCRouter({
};
}),
getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => {
const configuration = await getConfigurationAsync(ctx.db);
if (!configuration?.enabled || !configuration.modelId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
}
const discoveredModels = await getAssistantModelsAsync(configuration).catch(() => []);
const resolvedDefaultId = resolveAssistantModelId(discoveredModels, configuration.modelId) ?? configuration.modelId;
const defaultModel = discoveredModels.find((model) => model.id === resolvedDefaultId) ?? {
id: resolvedDefaultId,
name: resolvedDefaultId,
description: null,
contextLength: null,
promptPrice: null,
completionPrice: null,
inputModalities: [],
toolSupport: "unknown" as const,
};
return {
provider: configuration.provider,
defaultModelId: defaultModel.id,
models: [defaultModel, ...discoveredModels.filter((model) => model.id !== defaultModel.id)],
};
}),
getAdminConfiguration: adminProcedure.query(async ({ ctx }) => {
const configuration = await getConfigurationAsync(ctx.db);
return {
@@ -540,7 +582,7 @@ export const assistantRouter = createTRPCRouter({
if (!configuration) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Save a provider connection first." });
}
return await fetchModelsAsync(configuration);
return await getAssistantModelsAsync(configuration, true);
}),
updateConfiguration: adminProcedure
@@ -590,6 +632,31 @@ export const assistantRouter = createTRPCRouter({
return { id };
}),
updateThreadModel: protectedProcedure
.input(z.object({ threadId: z.string().max(64), modelId: z.string().trim().min(1).max(256) }))
.mutation(async ({ ctx, input }) => {
const [thread, configuration] = await Promise.all([
ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id),
getConfigurationAsync(ctx.db),
]);
if (!configuration?.enabled || !configuration.modelId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
}
const selectedModel = await getSelectedModelDetailsAsync(configuration, input.modelId).catch(() => null);
if (input.modelId !== configuration.modelId && !selectedModel) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The selected model is not available from the configured provider.",
});
}
const modelId = selectedModel?.id ?? configuration.modelId;
await ctx.db
.update(assistantThreads)
.set({ modelId, updatedAt: new Date() })
.where(eq(assistantThreads.id, thread.id));
return { modelId };
}),
getThread: protectedProcedure.input(z.object({ threadId: z.string().max(64) })).query(async ({ ctx, input }) => {
const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
const messages = await ctx.db.query.assistantMessages.findMany({
@@ -10,6 +10,7 @@ import {
apps,
assistantConfigurations,
assistantMessages,
assistantThreads,
boards,
integrations,
integrationUserPermissions,
@@ -141,6 +142,59 @@ describe("assistantRouter.updateConfiguration", () => {
});
describe("assistant conversation features", () => {
test("returns safe runtime model options to signed-in users", async () => {
const db = await createConfiguredAssistantAsync();
await db
.update(assistantConfigurations)
.set({
baseUrl: "https://models.example/v1",
encryptedHeaders: null,
updatedAt: new Date("2026-07-30T14:00:00.000Z"),
})
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "example/model",
name: "Example Model",
supported_parameters: ["tools"],
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
},
{
id: "example/no-tools",
name: "No tools",
supported_parameters: [],
architecture: { output_modalities: ["text"] },
},
],
}),
),
);
try {
const options = await caller.getRuntimeOptions();
expect(options).toEqual({
provider: "openrouter",
defaultModelId: "example/model",
models: [
expect.objectContaining({
id: "example/model",
name: "Example Model",
inputModalities: ["text", "image"],
}),
],
});
expect(JSON.stringify(options)).not.toContain("encrypted");
} finally {
vi.unstubAllGlobals();
}
});
test("returns permission-checked entities for composer mentions", async () => {
const db = await createConfiguredAssistantAsync();
await db.insert(users).values({ id: adminSession.user.id });
@@ -172,6 +226,53 @@ describe("assistant conversation features", () => {
);
});
test("persists a discovered model choice on the owned conversation", async () => {
const db = await createConfiguredAssistantAsync();
await db.insert(users).values({ id: adminSession.user.id });
await db
.update(assistantConfigurations)
.set({
baseUrl: "https://thread-models.example/v1",
encryptedHeaders: null,
updatedAt: new Date("2026-07-30T15:00:00.000Z"),
})
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const thread = await caller.createThread();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "example/model",
name: "Default",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
{
id: "example/reasoning",
name: "Reasoning",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
);
try {
await caller.updateThreadModel({ threadId: thread.id, modelId: "example/reasoning" });
} finally {
vi.unstubAllGlobals();
}
const storedThread = await db.query.assistantThreads.findFirst({
where: eq(assistantThreads.id, thread.id),
});
expect(storedThread?.modelId).toBe("example/reasoning");
});
test("persists feedback on an owned assistant message", async () => {
const db = await createConfiguredAssistantAsync();
await db.insert(users).values({ id: adminSession.user.id });