add OpenRouter web search option

This commit is contained in:
ajnart committed 2026-08-03 16:59:27 +02:00
1 parent 35673f113e
commit f7b291b465
25 files changed
+5753 -18

No files matched your search

+10 -5
View File
@@ -85,11 +85,16 @@ The configured endpoint must support streaming Chat Completions and structured t
assistant experience. Model discovery alone does not guarantee tool support, except when OpenRouter
explicitly advertises the model's `tools` capability.
OpenRouter connections also expose OpenRouter's provider-executed web-search server tool to the model. When
a user explicitly asks for live research or current external information, the assistant can search without
receiving the stored OpenRouter credential in the browser. Search results can be used in dashboard notes and
responses, including source links. Web search has separate OpenRouter usage costs and remains unavailable for
other OpenAI-compatible endpoints unless those providers expose their own supported search tool.
OpenRouter web search is an opt-in option in the model section. It registers OpenRouter's model-callable
[`openrouter:web_search` server tool](https://openrouter.ai/docs/guides/features/server-tools/web-search)
alongside Homarr's tools, so OpenRouter performs each search without
exposing the stored credential to the browser. The model decides when live research is needed and can use the
grounded results and source links in responses or dashboard notes. Homarr limits each assistant request to
three searches with five results per search. OpenRouter charges web-search usage separately from model tokens.
The option is also available for custom endpoints that forward OpenRouter's server-tools request format. This
supports OpenRouter proxies, including the future Homarr Router provider. Leave it disabled for ordinary
OpenAI-compatible endpoints because the `openrouter:*` tool type is not part of the standard OpenAI API.
The Claude preset uses Anthropic's OpenAI SDK compatibility layer. Anthropic describes that layer as
suited to testing and comparison rather than as its full native API; unsupported OpenAI fields may be
@@ -104,6 +104,7 @@
flex-shrink: 0;
}
.capabilityRow,
.enableRow {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-sm);
@@ -160,6 +161,10 @@
justify-content: flex-start;
flex-shrink: 1;
}
.capabilityRow {
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -36,6 +36,7 @@ import {
IconKey,
IconLock,
IconPlus,
IconSearch,
IconShieldCheck,
IconShieldLock,
IconTool,
@@ -45,6 +46,7 @@ import {
import { clientApi } from "@homarr/api/client";
import {
assistantProviderCanUseOpenRouterServerTools,
assistantProviderIds,
assistantProviderPresets,
getAssistantModelOptionLabel,
@@ -143,6 +145,7 @@ export const AssistantConfiguration = () => {
const [nextHeaderId, setNextHeaderId] = useState(1);
const [modelId, setModelId] = useState("");
const [enabled, setEnabled] = useState(false);
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
const [clearCredentialsOpened, setClearCredentialsOpened] = useState(false);
const replacementKeyInputRef = useRef<HTMLInputElement>(null);
@@ -152,6 +155,7 @@ export const AssistantConfiguration = () => {
setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? "");
setModelId(configuration?.modelId ?? "");
setEnabled(configuration?.enabled ?? false);
setWebSearchEnabled(configuration?.webSearchEnabled ?? false);
setApiKey("");
setCredentialFlow("idle");
setCredentialStep(0);
@@ -243,6 +247,7 @@ export const AssistantConfiguration = () => {
baseUrl.trim().length > 0 && headerValuesValid && (!preset.requiresApiKey || hasEffectiveApiKey);
const modelControlsDisabled = connectionPending || isDiscovering;
const canSaveConfiguration = !modelControlsDisabled && modelId.trim().length > 0;
const canUseOpenRouterServerTools = assistantProviderCanUseOpenRouterServerTools(provider);
const updateConnection = clientApi.assistant.updateConnection.useMutation({
onSuccess: async ({ credentialsClearedForDestinationChange }) => {
@@ -315,6 +320,7 @@ export const AssistantConfiguration = () => {
setClearHeaders(false);
setModelId("");
setEnabled(false);
setWebSearchEnabled(false);
};
const changeProvider = (value: string | null) => {
@@ -368,7 +374,11 @@ export const AssistantConfiguration = () => {
const saveAssistantConfiguration = () => {
if (!canSaveConfiguration) return;
saveConfiguration.mutate({ enabled, modelId: modelId.trim() });
saveConfiguration.mutate({
enabled,
modelId: modelId.trim(),
webSearchEnabled: canUseOpenRouterServerTools && webSearchEnabled,
});
};
if (isLoading) {
@@ -406,6 +416,11 @@ export const AssistantConfiguration = () => {
</Box>
</Group>
<Group className={classes.summaryActions} gap="xs">
{configuration?.webSearchEnabled && (
<Badge variant="light" leftSection={<IconSearch size={12} />}>
{t("serverTools.webSearch.label")}
</Badge>
)}
{hasStoredApiKey && (
<Badge variant="light" color="teal" leftSection={<IconLock size={12} />}>
{t("overview.keyProtected")}
@@ -850,6 +865,44 @@ export const AssistantConfiguration = () => {
</Group>
)}
{canUseOpenRouterServerTools && (
<Stack gap="xs">
<Divider label={t("serverTools.title")} labelPosition="left" />
<Group className={classes.capabilityRow} justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap">
<ThemeIcon variant="light" radius="xl" size="md">
<IconSearch size={17} />
</ThemeIcon>
<Box>
<Group gap="xs">
<Text size="sm" fw={600}>
{t("serverTools.webSearch.label")}
</Text>
<Badge size="xs" variant="light">
{t("serverTools.beta")}
</Badge>
</Group>
<Text size="sm" c="dimmed" maw="66ch">
{provider === "custom"
? t("serverTools.webSearch.proxyDescription")
: t("serverTools.webSearch.description")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t("serverTools.webSearch.limit")}
</Text>
</Box>
</Group>
<Switch
checked={webSearchEnabled}
onChange={(event) => setWebSearchEnabled(event.currentTarget.checked)}
disabled={modelControlsDisabled}
aria-label={t("serverTools.webSearch.label")}
size="md"
/>
</Group>
</Stack>
)}
<Group className={classes.enableRow} justify="space-between" align="center" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { withOpenRouterWebSearch } from "./assistant-openrouter";
import { getOpenRouterWebSearchRequests, withOpenRouterWebSearch } from "./assistant-openrouter";
describe("withOpenRouterWebSearch", () => {
test("adds the current OpenRouter server tool alongside Homarr function tools", () => {
@@ -12,7 +12,7 @@ describe("withOpenRouterWebSearch", () => {
).toMatchObject({
tools: [
{ type: "function", function: { name: "board_addItem" } },
{ type: "openrouter:web_search", parameters: { max_results: 5 } },
{ type: "openrouter:web_search", parameters: { max_results: 5, max_uses: 3 } },
],
});
});
@@ -22,4 +22,10 @@ describe("withOpenRouterWebSearch", () => {
expect(withOpenRouterWebSearch(body)).toBe(body);
});
test("reads OpenRouter server-tool usage from a response", () => {
expect(getOpenRouterWebSearchRequests({ usage: { server_tool_use: { web_search_requests: 2 } } })).toBe(2);
expect(getOpenRouterWebSearchRequests({ usage: {} })).toBeUndefined();
expect(getOpenRouterWebSearchRequests({ usage: { server_tool_use: { web_search_requests: -1 } } })).toBeUndefined();
});
});
@@ -1,6 +1,6 @@
const openRouterWebSearchTool = {
type: "openrouter:web_search",
parameters: { max_results: 5 },
parameters: { max_results: 5, max_uses: 3 },
} as const;
export const withOpenRouterWebSearch = (body: Record<string, unknown>) => {
@@ -19,3 +19,15 @@ export const withOpenRouterWebSearch = (body: Record<string, unknown>) => {
return { ...body, tools: [...tools, openRouterWebSearchTool] };
};
export const getOpenRouterWebSearchRequests = (value: unknown) => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
const usage = "usage" in value && typeof value.usage === "object" && value.usage !== null ? value.usage : undefined;
const serverToolUse =
usage && "server_tool_use" in usage && typeof usage.server_tool_use === "object" && usage.server_tool_use !== null
? usage.server_tool_use
: undefined;
const requests =
serverToolUse && "web_search_requests" in serverToolUse ? Number(serverToolUse.web_search_requests) : Number.NaN;
return Number.isFinite(requests) && requests >= 0 ? requests : undefined;
};
@@ -20,7 +20,11 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq } from "@homarr/db";
import { db } from "@homarr/db";
import { assistantConfigurations, assistantThreads } from "@homarr/db/schema";
import { assistantProviderRequiresApiKey, assistantReasoningModes } from "@homarr/definitions";
import {
assistantProviderCanUseOpenRouterServerTools,
assistantProviderRequiresApiKey,
assistantReasoningModes,
} from "@homarr/definitions";
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
import type {
@@ -32,7 +36,7 @@ import type {
import { extractMcpTools } from "../../mcp/_extract-tools";
import { getRequestedMentionIds, sanitizeAttachmentFilename } from "./assistant-chat-input";
import { getAssistantModelLookupStatus } from "./assistant-model-lookup";
import { withOpenRouterWebSearch } from "./assistant-openrouter";
import { getOpenRouterWebSearchRequests, withOpenRouterWebSearch } from "./assistant-openrouter";
import { assistantExecutionPolicy } from "./assistant-execution-policy";
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
import { getForcedAssistantToolName, withAssistantToolPolicy } from "./assistant-tool-policy";
@@ -106,7 +110,6 @@ Action rules:
- To create a formatted note on a dashboard, call configure_widget with kind "notebook". Put valid Tiptap-compatible HTML in options.content and set options.showToolbar and options.allowReadOnlyCheck when useful. Use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images as appropriate. Never include scripts, styles, iframes, event handlers, or unsafe URL protocols.
- When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL.
- When a Homarr tool returns a usable image or icon URL and a visual summary helps, embed that exact URL with Markdown image syntax. App summary tables must use an Icon column such as ![Discord icon](exact-returned-url). Never replace an available returned icon with emoji, and never invent or transform image URLs.
- When the configured provider is OpenRouter and the user asks for live web research or current external information, use OpenRouter's web search server tool. Ground the answer in its results and preserve useful source links in the response or generated note. Do not claim web search is unavailable when that tool is present. For other providers, only claim live research when an actual search tool is available.
- Complete batch requests in the same run. Use parallel independent read-only calls when supported, reuse results, continue calling tools until every requested item has been attempted, and summarize only after the batch is complete. Do not stop after a partial batch or ask the user to type "Continue".
- Browser tools can navigate within Homarr, open existing Homarr UI, or refresh the active view after a completed change. Never navigate to an arbitrary external URL and never refresh before a mutation finishes.
- Keep responses concise and lead with the result. Summarize tool output instead of dumping JSON.
@@ -115,6 +118,10 @@ Action rules:
Critical approval protocol: when a mutation's inputs are known, your next response must contain the mutation tool call, not a confirmation question. Homarr itself obtains approval after the tool call and before execution.`;
const webSearchInstructions = `
OpenRouter web search is enabled for this request. When the user asks for live research or current external information, use the openrouter:web_search server tool. Ground the answer in its results and preserve useful source links in the response or generated note. Do not claim web search is unavailable.`;
const asRecord = (value: unknown): Record<string, unknown> | null =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
@@ -131,6 +138,7 @@ const createProviderTelemetryExtractor = (): MetadataExtractor => {
if (!body) return;
const usage = asRecord(body.usage);
const costDetails = asRecord(usage?.cost_details);
const webSearchRequests = getOpenRouterWebSearchRequests(body);
const routerMetadata = asRecord(body.openrouter_metadata);
const endpoints = asRecord(routerMetadata?.endpoints);
const selectedEndpoint = Array.isArray(endpoints?.available)
@@ -154,6 +162,7 @@ const createProviderTelemetryExtractor = (): MetadataExtractor => {
...(routedProvider ? { routedProvider } : {}),
...(cost !== undefined ? { cost } : {}),
...(upstreamCost !== undefined ? { upstreamCost } : {}),
...(webSearchRequests !== undefined ? { webSearchRequests } : {}),
...(routerAttempt !== undefined ? { fallbackCount: Math.max(routerAttempt - 1, 0) } : {}),
...(typeof routerMetadata?.strategy === "string" ? { routerStrategy: routerMetadata.strategy } : {}),
...(typeof routerMetadata?.region === "string" ? { routerRegion: routerMetadata.region } : {}),
@@ -479,13 +488,15 @@ export async function POST(request: Request) {
) satisfies ToolSet;
const availableTools = { ...homarrTools, ...frontendTools };
const forcedToolName = getForcedAssistantToolName(parsed.data.messages as UIMessage[]);
const openRouterServerToolsEnabled =
configuration.webSearchEnabled && assistantProviderCanUseOpenRouterServerTools(configuration.provider);
try {
const customHeaders = configuration.encryptedHeaders
? z.record(z.string(), z.string()).parse(JSON.parse(decryptSecret(configuration.encryptedHeaders)))
: {};
const providerHeaders = {
...(configuration.provider === "openrouter"
...(configuration.provider === "openrouter" || openRouterServerToolsEnabled
? {
"HTTP-Referer": "https://homarr.dev",
"X-Title": "Homarr Assistant",
@@ -502,7 +513,7 @@ export async function POST(request: Request) {
apiKey: providerApiKey,
headers: providerHeaders,
includeUsage: true,
transformRequestBody: configuration.provider === "openrouter" ? withOpenRouterWebSearch : undefined,
transformRequestBody: openRouterServerToolsEnabled ? withOpenRouterWebSearch : undefined,
metadataExtractor: createProviderTelemetryExtractor(),
});
@@ -514,7 +525,7 @@ export async function POST(request: Request) {
let hasUpstreamCost = false;
const result = streamText({
model: provider(modelId),
instructions: `${assistantInstructions}${mentionContext}`,
instructions: `${assistantInstructions}${openRouterServerToolsEnabled ? webSearchInstructions : ""}${mentionContext}`,
messages: await convertToModelMessages(prepareMessagesForModel(parsed.data.messages as UIMessage[])),
tools: availableTools,
prepareStep: ({ stepNumber }) => {
@@ -535,7 +546,9 @@ export async function POST(request: Request) {
maxRetries: 2,
reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning,
providerOptions:
configuration.provider === "openrouter" ? { [providerName]: { usage: { include: true } } } : undefined,
configuration.provider === "openrouter" || openRouterServerToolsEnabled
? { [providerName]: { usage: { include: true } } }
: undefined,
toolApproval,
experimental_toolApprovalSecret: getToolApprovalSecret(),
onChunk: ({ chunk }) => {
@@ -579,6 +592,9 @@ export async function POST(request: Request) {
...(asFiniteNumber(telemetry?.upstreamCost) !== undefined
? { upstreamCost: asFiniteNumber(telemetry?.upstreamCost) }
: {}),
...(asFiniteNumber(telemetry?.webSearchRequests) !== undefined
? { webSearchRequests: asFiniteNumber(telemetry?.webSearchRequests) }
: {}),
...(generationId ? { generationId } : {}),
...(generationId
? {
@@ -689,6 +705,7 @@ export async function POST(request: Request) {
: undefined;
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);
return {
usage,
@@ -709,6 +726,7 @@ export async function POST(request: Request) {
...(hasUpstreamCost ? { upstreamCost } : {}),
...(cacheDiscount !== undefined ? { cacheDiscount } : {}),
...(fallbackCount !== undefined ? { fallbackCount } : {}),
...(webSearchRequests > 0 ? { webSearchRequests } : {}),
finishReason: latestStep?.finishReason ?? part.finishReason,
},
},
@@ -23,6 +23,7 @@ describe("getAssistantTelemetry", () => {
generationTimeMs: 1451,
cacheDiscount: 0.00003,
fallbackCount: 1,
webSearchRequests: 2,
steps: [
{
index: 1,
@@ -40,6 +41,7 @@ describe("getAssistantTelemetry", () => {
reasoningTokens: 7,
cacheDiscount: 0.00003,
fallbackCount: 1,
webSearchRequests: 2,
routedProvider: "Alibaba",
serviceTier: "default",
dataRegion: "US",
@@ -61,6 +63,7 @@ describe("getAssistantTelemetry", () => {
generationTimeMs: 1451,
cacheDiscount: 0.00003,
fallbackCount: 1,
webSearchRequests: 2,
steps: [
expect.objectContaining({
providerLatencyMs: 964,
@@ -68,6 +71,7 @@ describe("getAssistantTelemetry", () => {
cachedInputTokens: 2048,
routedProvider: "Alibaba",
serviceTier: "default",
webSearchRequests: 2,
}),
],
});
@@ -40,6 +40,7 @@ export type AssistantRequestStep = {
routerRegion?: string;
fallbackCount?: number;
fallbackLatencyMs?: number;
webSearchRequests?: number;
isByok?: boolean;
streamed?: boolean;
cancelled?: boolean;
@@ -64,6 +65,7 @@ export type AssistantRequestTelemetry = {
cacheDiscount?: number;
fallbackCount?: number;
fallbackLatencyMs?: number;
webSearchRequests?: number;
costType?: "reported" | "estimated";
finishReason?: string;
steps: AssistantRequestStep[];
@@ -208,6 +210,9 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme
...(getFiniteNonNegativeNumber(candidate.fallbackLatencyMs) !== undefined
? { fallbackLatencyMs: getFiniteNonNegativeNumber(candidate.fallbackLatencyMs) }
: {}),
...(getFiniteNonNegativeNumber(candidate.webSearchRequests) !== undefined
? { webSearchRequests: getFiniteNonNegativeNumber(candidate.webSearchRequests) }
: {}),
...(typeof candidate.isByok === "boolean" ? { isByok: candidate.isByok } : {}),
...(typeof candidate.streamed === "boolean" ? { streamed: candidate.streamed } : {}),
...(typeof candidate.cancelled === "boolean" ? { cancelled: candidate.cancelled } : {}),
@@ -259,6 +264,9 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme
...(getFiniteNonNegativeNumber(value.fallbackLatencyMs) !== undefined
? { fallbackLatencyMs: getFiniteNonNegativeNumber(value.fallbackLatencyMs) }
: {}),
...(getFiniteNonNegativeNumber(value.webSearchRequests) !== undefined
? { webSearchRequests: getFiniteNonNegativeNumber(value.webSearchRequests) }
: {}),
...(value.costType === "reported" || value.costType === "estimated" ? { costType: value.costType } : {}),
...(typeof value.finishReason === "string" ? { finishReason: value.finishReason } : {}),
};
@@ -1052,6 +1052,16 @@ const RequestTelemetry = () => {
</Text>
</div>
)}
{telemetry.webSearchRequests !== undefined && (
<div>
<Text size="xs" c="dimmed">
{t("usage.webSearches")}
</Text>
<Text size="sm" fw={600}>
{telemetry.webSearchRequests.toLocaleString()}
</Text>
</div>
)}
{telemetry.finishReason && (
<div>
<Text size="xs" c="dimmed">
+20 -2
View File
@@ -19,6 +19,7 @@ import {
items,
} from "@homarr/db/schema";
import {
assistantProviderCanUseOpenRouterServerTools,
assistantProviderIds,
assistantProviderPresets,
assistantProviderRequiresApiKey,
@@ -517,6 +518,7 @@ export const assistantRouter = createTRPCRouter({
return {
connectionConfigured: Boolean(configuration),
enabled: configuration?.enabled ?? false,
webSearchEnabled: configuration?.webSearchEnabled ?? false,
provider: configuration?.provider ?? "openrouter",
baseUrl: configuration?.baseUrl ?? "https://openrouter.ai/api/v1",
modelDiscoveryPath: configuration?.modelDiscoveryPath ?? "/models",
@@ -569,6 +571,10 @@ export const assistantRouter = createTRPCRouter({
encryptedHeaders,
modelId: connectionChanged ? null : existing.modelId,
enabled: connectionChanged ? false : existing.enabled,
webSearchEnabled:
destinationChanged || !assistantProviderCanUseOpenRouterServerTools(input.provider)
? false
: existing.webSearchEnabled,
updatedAt: new Date(),
})
.where(eq(assistantConfigurations.id, configurationId));
@@ -614,7 +620,13 @@ export const assistantRouter = createTRPCRouter({
}),
updateConfiguration: adminProcedure
.input(z.object({ enabled: z.boolean(), modelId: z.string().trim().min(1).max(256) }))
.input(
z.object({
enabled: z.boolean(),
modelId: z.string().trim().min(1).max(256),
webSearchEnabled: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
const configuration = await getConfigurationAsync(ctx.db);
if (!configuration) {
@@ -624,6 +636,12 @@ export const assistantRouter = createTRPCRouter({
if (input.enabled && requiresApiKey && !configuration.encryptedApiKey) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This provider requires an API key." });
}
if (input.webSearchEnabled && !assistantProviderCanUseOpenRouterServerTools(configuration.provider)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Web search requires OpenRouter or an OpenRouter-compatible proxy endpoint.",
});
}
const discoveredModels = configuration.modelDiscoveryPath
? await fetchModelsAsync(configuration).catch(() => null)
: null;
@@ -632,7 +650,7 @@ export const assistantRouter = createTRPCRouter({
: input.modelId;
await ctx.db
.update(assistantConfigurations)
.set({ enabled: input.enabled, modelId, updatedAt: new Date() })
.set({ enabled: input.enabled, modelId, webSearchEnabled: input.webSearchEnabled, updatedAt: new Date() })
.where(eq(assistantConfigurations.id, configurationId));
}),
@@ -82,6 +82,26 @@ describe("assistantRouter.updateConnection", () => {
});
});
test("disables OpenRouter server tools when the provider destination changes", async () => {
const db = await createConfiguredAssistantAsync();
await db
.update(assistantConfigurations)
.set({ webSearchEnabled: true })
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await caller.updateConnection({
provider: "openai",
baseUrl: "https://api.openai.com/v1",
modelDiscoveryPath: "/models",
clearApiKey: false,
clearCustomHeaders: false,
});
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration?.webSearchEnabled).toBe(false);
});
test("preserves the persisted model when the saved connection is unchanged", async () => {
const db = await createConfiguredAssistantAsync();
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
@@ -139,6 +159,29 @@ describe("assistantRouter.updateConfiguration", () => {
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration?.modelId).toBe("deepseek/deepseek-v4-pro");
});
test("persists the OpenRouter web-search server tool option", async () => {
const db = await createConfiguredAssistantAsync();
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await caller.updateConfiguration({ enabled: true, modelId: "example/model", webSearchEnabled: true });
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration?.webSearchEnabled).toBe(true);
});
test("rejects OpenRouter server tools for an incompatible provider", async () => {
const db = await createConfiguredAssistantAsync();
await db
.update(assistantConfigurations)
.set({ provider: "openai", baseUrl: "https://api.openai.com/v1" })
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
await expect(
caller.updateConfiguration({ enabled: true, modelId: "gpt-5", webSearchEnabled: true }),
).rejects.toThrow("Web search requires OpenRouter or an OpenRouter-compatible proxy endpoint.");
});
});
describe("assistant conversation features", () => {
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import {
assistantProviderCanUseOpenRouterServerTools,
assistantProviderIds,
assistantProviderPresets,
assistantProviderRequiresApiKey,
@@ -47,4 +48,12 @@ describe("assistant provider presets", () => {
expect(resolveAssistantModelId([model], model.id)).toBe(model.id);
expect(resolveAssistantModelId([model], getAssistantModelOptionLabel(model))).toBe(model.id);
});
test("allows OpenRouter server tools for OpenRouter, its Homarr proxy, and explicit custom proxies", () => {
expect(assistantProviderCanUseOpenRouterServerTools("openrouter")).toBe(true);
expect(assistantProviderCanUseOpenRouterServerTools("homarr")).toBe(true);
expect(assistantProviderCanUseOpenRouterServerTools("custom")).toBe(true);
expect(assistantProviderCanUseOpenRouterServerTools("openai")).toBe(false);
expect(assistantProviderCanUseOpenRouterServerTools("anthropic")).toBe(false);
});
});
@@ -0,0 +1 @@
ALTER TABLE `assistant_configuration` ADD `web_search_enabled` boolean DEFAULT false NOT NULL;
@@ -316,6 +316,13 @@
"when": 1785232800000,
"tag": "0044_add_assistant",
"breakpoints": true
},
{
"idx": 45,
"version": "5",
"when": 1785768750000,
"tag": "0045_add_assistant_web_search",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE "assistant_configuration" ADD COLUMN "web_search_enabled" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -78,6 +78,13 @@
"when": 1785252781398,
"tag": "0010_workable_shard",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1785768744667,
"tag": "0011_same_sir_ram",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE `assistant_configuration` ADD `web_search_enabled` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -302,6 +302,13 @@
"when": 1785252768815,
"tag": "0042_bumpy_tattoo",
"breakpoints": true
},
{
"idx": 43,
"version": "6",
"when": 1785768736378,
"tag": "0043_swift_silver_samurai",
"breakpoints": true
}
]
}
+1
View File
@@ -484,6 +484,7 @@ export const serverSettings = mysqlTable("serverSetting", {
export const assistantConfigurations = mysqlTable("assistant_configuration", {
id: varchar({ length: 64 }).notNull().primaryKey().default("default"),
enabled: boolean().notNull().default(false),
webSearchEnabled: boolean().notNull().default(false),
provider: varchar({ length: 32 })
.$type<
| "openrouter"
+1
View File
@@ -483,6 +483,7 @@ export const serverSettings = pgTable("serverSetting", {
export const assistantConfigurations = pgTable("assistant_configuration", {
id: varchar({ length: 64 }).notNull().primaryKey().default("default"),
enabled: boolean().notNull().default(false),
webSearchEnabled: boolean().notNull().default(false),
provider: varchar({ length: 32 })
.$type<
| "openrouter"
+1
View File
@@ -469,6 +469,7 @@ export const serverSettings = sqliteTable("serverSetting", {
export const assistantConfigurations = sqliteTable("assistant_configuration", {
id: text().notNull().primaryKey().default("default"),
enabled: int({ mode: "boolean" }).notNull().default(false),
webSearchEnabled: int({ mode: "boolean" }).notNull().default(false),
provider: text()
.$type<
| "openrouter"
+7
View File
@@ -142,3 +142,10 @@ export const assistantProviderPresets = {
export const assistantProviderRequiresApiKey = (provider: AssistantProvider) =>
assistantProviderPresets[provider].requiresApiKey;
/**
* OpenRouter and Homarr Router implement OpenRouter server tools directly.
* Custom endpoints may opt in when they proxy the OpenRouter request format.
*/
export const assistantProviderCanUseOpenRouterServerTools = (provider: AssistantProvider | "homarr") =>
provider === "openrouter" || provider === "homarr" || provider === "custom";
+11
View File
@@ -1648,6 +1648,7 @@
"reported": "Reported by provider",
"estimated": "Estimated from model pricing",
"upstreamCost": "Upstream inference",
"webSearches": "Web searches",
"finishReason": "Finish reason",
"notReported": "Not reported",
"agentSteps": "Agent steps",
@@ -5334,6 +5335,16 @@
"toolsConfirmed": "Tools advertised",
"toolsUnknown": "Verify tool support"
},
"serverTools": {
"title": "Server tools",
"beta": "Beta",
"webSearch": {
"label": "Web search",
"description": "Allow the model to search the live web through OpenRouter when a request needs current information. Search usage is charged separately by OpenRouter.",
"proxyDescription": "Send OpenRouter's web-search server tool to this endpoint. Enable this only when the custom endpoint forwards the OpenRouter server-tools format, such as a Homarr Router proxy.",
"limit": "The model decides when to search, with a limit of 3 searches and 5 results per search for each request."
}
},
"enabled": {
"label": "Enable Assistant",
"description": "Shows the assistant to all signed-in users. Existing Homarr permissions still apply to every internal tool call."