feat: improve assistant interactions

This commit is contained in:
ajnart committed 2026-08-04 13:21:39 +02:00
1 parent 1642a862d9
commit 8e557fb9fd
25 files changed
+1345 -244

No files matched your search

@@ -1,6 +1,11 @@
import { describe, expect, test } from "vitest";
import { getOpenRouterWebSearchRequests, withOpenRouterWebSearch } from "./assistant-openrouter";
import {
getOpenRouterWebSearchRequests,
getOpenRouterWebSearchSources,
normalizeOpenRouterWebSearchSources,
withOpenRouterWebSearch,
} from "./assistant-openrouter";
describe("withOpenRouterWebSearch", () => {
test("adds the current OpenRouter server tool alongside Homarr function tools", () => {
@@ -28,4 +33,62 @@ describe("withOpenRouterWebSearch", () => {
expect(getOpenRouterWebSearchRequests({ usage: {} })).toBeUndefined();
expect(getOpenRouterWebSearchRequests({ usage: { server_tool_use: { web_search_requests: -1 } } })).toBeUndefined();
});
test("extracts and deduplicates Chat Completions web-search citations", () => {
expect(
getOpenRouterWebSearchSources({
choices: [
{
delta: {
annotations: [
{
type: "url_citation",
url_citation: {
url: "https://example.com/plex",
title: "Self-host Plex",
content: "A long excerpt that must not be persisted.",
},
},
],
},
},
{
message: {
annotations: [
{
type: "url_citation",
url_citation: { url: "https://example.com/plex", title: "Duplicate" },
},
{
type: "url_citation",
url_citation: { url: "https://docs.plex.tv/install", title: "Plex documentation" },
},
],
},
},
],
}),
).toEqual([
{ url: "https://example.com/plex", title: "Self-host Plex" },
{ url: "https://docs.plex.tv/install", title: "Plex documentation" },
]);
});
test("rejects unsafe citations and normalizes persisted source metadata", () => {
expect(
getOpenRouterWebSearchSources({
annotations: [
{ type: "url_citation", url_citation: { url: "javascript:alert(1)" } },
{ type: "url_citation", url_citation: { url: "https://user:password@example.com/private" } },
],
}),
).toEqual([]);
expect(
normalizeOpenRouterWebSearchSources([
{ url: "https://example.com/result", title: " Result " },
{ url: "https://example.com/result" },
{ url: "file:///etc/passwd", title: "Unsafe" },
]),
).toEqual([{ url: "https://example.com/result", title: "Result" }]);
});
});
@@ -3,6 +3,68 @@ const openRouterWebSearchTool = {
parameters: { max_results: 5, max_uses: 3 },
} as const;
export interface OpenRouterWebSearchSource {
url: string;
title?: string;
}
const maximumWebSearchSources = 12;
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
const getSafeWebSourceUrl = (value: unknown) => {
if (typeof value !== "string" || !URL.canParse(value)) return undefined;
const url = new URL(value);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) return undefined;
return url.href;
};
const getWebSearchSource = (value: unknown, requireCitationType: boolean): OpenRouterWebSearchSource | undefined => {
const annotation = asRecord(value);
if (!annotation || (requireCitationType && annotation.type !== "url_citation")) return undefined;
const citation = asRecord(annotation.url_citation) ?? annotation;
const url = getSafeWebSourceUrl(citation.url);
if (!url) return undefined;
const title = typeof citation.title === "string" ? citation.title.trim().slice(0, 200) : "";
return { url, ...(title ? { title } : {}) };
};
export const normalizeOpenRouterWebSearchSources = (value: unknown): OpenRouterWebSearchSource[] => {
if (!Array.isArray(value)) return [];
const sources = new Map<string, OpenRouterWebSearchSource>();
for (const item of value) {
const source = getWebSearchSource(item, false);
if (!source) continue;
const existing = sources.get(source.url);
sources.set(source.url, existing?.title || !source.title ? (existing ?? source) : source);
if (sources.size >= maximumWebSearchSources) break;
}
return [...sources.values()];
};
export const getOpenRouterWebSearchSources = (value: unknown): OpenRouterWebSearchSource[] => {
const sources = new Map<string, OpenRouterWebSearchSource>();
const visit = (candidate: unknown, depth: number) => {
if (depth > 8 || sources.size >= maximumWebSearchSources) return;
if (Array.isArray(candidate)) {
for (const item of candidate) visit(item, depth + 1);
return;
}
const record = asRecord(candidate);
if (!record) return;
const source = getWebSearchSource(record, true);
if (source) {
const existing = sources.get(source.url);
sources.set(source.url, existing?.title || !source.title ? (existing ?? source) : source);
}
for (const nested of Object.values(record)) visit(nested, depth + 1);
};
visit(value, 0);
return [...sources.values()];
};
export const withOpenRouterWebSearch = (body: Record<string, unknown>) => {
const tools = Array.isArray(body.tools) ? body.tools : [];
if (
@@ -0,0 +1,30 @@
import { TRPCError } from "@trpc/server";
import { describe, expect, test } from "vitest";
import { getSafeAssistantToolError } from "./assistant-tool-error";
describe("getSafeAssistantToolError", () => {
test("keeps authorization and compatibility failures actionable", () => {
expect(getSafeAssistantToolError(new TRPCError({ code: "FORBIDDEN" }))).toBe(
"You do not have permission to perform this action.",
);
expect(getSafeAssistantToolError(new TRPCError({ code: "NOT_FOUND" }))).toBe(
"The requested resource was not found or is not compatible with this tool.",
);
});
test("recognizes wrapped integration credential decryption failures", () => {
const cause = Object.assign(new Error("error:1C800064:Provider routines::bad decrypt"), {
code: "ERR_OSSL_BAD_DECRYPT",
});
const wrapped = new Error("Calendar tool failed", { cause });
expect(getSafeAssistantToolError(wrapped)).toContain("Re-save the integration credentials");
});
test("does not expose unexpected internal errors", () => {
expect(getSafeAssistantToolError(new Error("database password was secret"))).toBe(
"The Homarr tool could not complete this request.",
);
});
});
@@ -0,0 +1,41 @@
const asErrorRecord = (value: unknown): Record<string, unknown> | undefined =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
const getErrorChain = (error: unknown) => {
const chain: Record<string, unknown>[] = [];
let current = asErrorRecord(error);
while (current && chain.length < 8 && !chain.includes(current)) {
chain.push(current);
current = asErrorRecord(current.cause);
}
return chain;
};
export const getSafeAssistantToolError = (error: unknown) => {
const chain = getErrorChain(error);
if (
chain.some(
(candidate) =>
candidate.code === "ERR_OSSL_BAD_DECRYPT" ||
(typeof candidate.message === "string" && /bad decrypt|unable to decrypt/iu.test(candidate.message)),
)
) {
return "Homarr could not read this integration's saved credentials. Re-save the integration credentials in Management, then try again.";
}
const code = chain.find((candidate) => typeof candidate.code === "string")?.code;
switch (code) {
case "UNAUTHORIZED":
case "FORBIDDEN":
return "You do not have permission to perform this action.";
case "NOT_FOUND":
return "The requested resource was not found or is not compatible with this tool.";
case "BAD_REQUEST":
return "The tool input was not valid.";
case "PRECONDITION_FAILED":
return "The requested resource is not ready for this operation. Check its configuration and try again.";
case "TOO_MANY_REQUESTS":
return "The operation is rate limited. Try again later.";
}
return "The Homarr tool could not complete this request.";
};
+24 -20
View File
@@ -31,14 +31,21 @@ import type {
AssistantMessageMetadata,
AssistantRequestStep,
AssistantUsage,
AssistantWebSearchSource,
} from "~/components/assistant/assistant-message-metadata";
import { extractMcpTools } from "../../mcp/_extract-tools";
import { getRequestedMentionIds, sanitizeAttachmentFilename } from "./assistant-chat-input";
import { getAssistantModelLookupStatus } from "./assistant-model-lookup";
import { getOpenRouterWebSearchRequests, withOpenRouterWebSearch } from "./assistant-openrouter";
import {
getOpenRouterWebSearchRequests,
getOpenRouterWebSearchSources,
normalizeOpenRouterWebSearchSources,
withOpenRouterWebSearch,
} from "./assistant-openrouter";
import { assistantExecutionPolicy } from "./assistant-execution-policy";
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
import { getSafeAssistantToolError } from "./assistant-tool-error";
import { getForcedAssistantToolName, withAssistantToolPolicy } from "./assistant-tool-policy";
export const maxDuration = 300;
@@ -132,13 +139,18 @@ const asFiniteNumber = (value: unknown) => {
const createProviderTelemetryExtractor = (): MetadataExtractor => {
const createAccumulator = () => {
let metadata: Record<string, string | number> = {};
let metadata: Record<string, string | number | AssistantWebSearchSource[]> = {};
const webSearchSources = new Map<string, AssistantWebSearchSource>();
const process = (value: unknown) => {
const body = asRecord(value);
if (!body) return;
const usage = asRecord(body.usage);
const costDetails = asRecord(usage?.cost_details);
const webSearchRequests = getOpenRouterWebSearchRequests(body);
for (const source of getOpenRouterWebSearchSources(body)) {
const existing = webSearchSources.get(source.url);
webSearchSources.set(source.url, existing?.title || !source.title ? (existing ?? source) : source);
}
const routerMetadata = asRecord(body.openrouter_metadata);
const endpoints = asRecord(routerMetadata?.endpoints);
const selectedEndpoint = Array.isArray(endpoints?.available)
@@ -163,6 +175,7 @@ const createProviderTelemetryExtractor = (): MetadataExtractor => {
...(cost !== undefined ? { cost } : {}),
...(upstreamCost !== undefined ? { upstreamCost } : {}),
...(webSearchRequests !== undefined ? { webSearchRequests } : {}),
...(webSearchSources.size > 0 ? { webSearchSources: [...webSearchSources.values()] } : {}),
...(routerAttempt !== undefined ? { fallbackCount: Math.max(routerAttempt - 1, 0) } : {}),
...(typeof routerMetadata?.strategy === "string" ? { routerStrategy: routerMetadata.strategy } : {}),
...(typeof routerMetadata?.region === "string" ? { routerRegion: routerMetadata.region } : {}),
@@ -295,23 +308,6 @@ const sumCompleteStepMetric = (
return (values as number[]).reduce((sum, value) => sum + value, 0);
};
const safeToolError = (error: unknown) => {
if (error instanceof Error && "code" in error) {
switch ((error as Error & { code: string }).code) {
case "UNAUTHORIZED":
case "FORBIDDEN":
return "You do not have permission to perform this action.";
case "NOT_FOUND":
return "The requested resource was not found.";
case "BAD_REQUEST":
return "The tool input was not valid.";
case "TOO_MANY_REQUESTS":
return "The operation is rate limited. Try again later.";
}
}
return "The Homarr tool could not complete this request.";
};
const getProcedureTypeMap = () => {
const procedures = (
mcpRouter as unknown as {
@@ -456,7 +452,7 @@ export async function POST(request: Request) {
toolName: mcpTool.name,
error: error instanceof Error ? error.message : String(error),
});
return { error: safeToolError(error) };
return { error: getSafeAssistantToolError(error) };
}
},
}),
@@ -569,6 +565,7 @@ export async function POST(request: Request) {
},
onStepFinish: ({ performance, providerMetadata, response, usage }) => {
const telemetry = asRecord(providerMetadata?.homarrTelemetry);
const webSearchSources = normalizeOpenRouterWebSearchSources(telemetry?.webSearchSources);
const generationId =
typeof telemetry?.generationId === "string"
? telemetry.generationId
@@ -595,6 +592,7 @@ export async function POST(request: Request) {
...(asFiniteNumber(telemetry?.webSearchRequests) !== undefined
? { webSearchRequests: asFiniteNumber(telemetry?.webSearchRequests) }
: {}),
...(webSearchSources.length > 0 ? { webSearchSources } : {}),
...(generationId ? { generationId } : {}),
...(generationId
? {
@@ -706,6 +704,11 @@ export async function POST(request: Request) {
const cacheDiscount = sumCompleteStepMetric(requestSteps, (step) => step.cacheDiscount);
const fallbackCount = sumCompleteStepMetric(requestSteps, (step) => step.fallbackCount);
const webSearchRequests = requestSteps.reduce((sum, step) => sum + (step.webSearchRequests ?? 0), 0);
const webSearchSources = [
...new Map(
requestSteps.flatMap((step) => step.webSearchSources ?? []).map((source) => [source.url, source] as const),
).values(),
];
return {
usage,
@@ -727,6 +730,7 @@ export async function POST(request: Request) {
...(cacheDiscount !== undefined ? { cacheDiscount } : {}),
...(fallbackCount !== undefined ? { fallbackCount } : {}),
...(webSearchRequests > 0 ? { webSearchRequests } : {}),
...(webSearchSources.length > 0 ? { webSearchSources } : {}),
finishReason: latestStep?.finishReason ?? part.finishReason,
},
},