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

+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);
});
});