feat: improve assistant interactions
This commit is contained in:
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.";
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user