feat: expand assistant providers and polish experience

This commit is contained in:
ajnart committed 2026-07-30 00:39:22 +02:00
1 parent f90fb4512b
commit 1c3aac5e82
13 files changed
+446 -72

No files matched your search

+8 -2
View File
@@ -21,8 +21,9 @@ It is available to signed-in users from the header, the user menu, and the `Cmd/
### Configure a model provider
Homarr supports OpenRouter, OpenAI, Ollama, LM Studio, and custom services that implement the OpenAI
Chat Completions API.
Homarr includes presets for OpenRouter, OpenAI, Anthropic Claude, Google Gemini, xAI, Groq, Mistral AI,
DeepSeek, Together AI, Ollama, and LM Studio. A custom preset connects any other service that implements
the OpenAI Chat Completions API.
1. Choose a provider preset or **Custom endpoint**.
2. Confirm the API base URL. It usually ends in `/v1`.
@@ -52,6 +53,11 @@ 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.
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
ignored. Streaming responses and tool calls are supported, which are the capabilities Homarr Assistant
uses. Choose OpenRouter or another provider when you prefer a provider-neutral production endpoint.
### Conversations and tools
Conversations and messages are stored per user in the Homarr database. Users can start, revisit, archive,
@@ -28,51 +28,25 @@ import {
IconPlus,
IconRobot,
IconShieldLock,
IconTool,
IconTrash,
IconWorld,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { assistantProviderIds, assistantProviderPresets } from "@homarr/definitions";
import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
const providerPresets = {
openrouter: {
baseUrl: "https://openrouter.ai/api/v1",
discoveryPath: "/models",
requiresApiKey: true,
},
openai: {
baseUrl: "https://api.openai.com/v1",
discoveryPath: "/models",
requiresApiKey: true,
},
ollama: {
baseUrl: "http://localhost:11434/v1",
discoveryPath: "/models",
requiresApiKey: false,
},
"lm-studio": {
baseUrl: "http://localhost:1234/v1",
discoveryPath: "/models",
requiresApiKey: false,
},
custom: {
baseUrl: "",
discoveryPath: "/models",
requiresApiKey: false,
},
} as const;
type Provider = keyof typeof providerPresets;
type HeaderEntry = { id: number; name: string; value: string };
export const AssistantSettings = () => {
const t = useScopedI18n("management.page.settings.section.assistant");
const utils = clientApi.useUtils();
const { data: configuration, isLoading } = clientApi.assistant.getAdminConfiguration.useQuery();
const [provider, setProvider] = useState<Provider>("openrouter");
const [baseUrl, setBaseUrl] = useState<string>(providerPresets.openrouter.baseUrl);
const [provider, setProvider] = useState<AssistantProvider>("openrouter");
const [baseUrl, setBaseUrl] = useState<string>(assistantProviderPresets.openrouter.baseUrl);
const [modelDiscoveryPath, setModelDiscoveryPath] = useState<string>("/models");
const [apiKey, setApiKey] = useState("");
const [clearApiKey, setClearApiKey] = useState(false);
@@ -84,7 +58,7 @@ export const AssistantSettings = () => {
useEffect(() => {
setProvider(configuration?.provider ?? "openrouter");
setBaseUrl(configuration?.baseUrl ?? providerPresets.openrouter.baseUrl);
setBaseUrl(configuration?.baseUrl ?? assistantProviderPresets.openrouter.baseUrl);
setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? "");
setModelId(configuration?.modelId ?? "");
setEnabled(configuration?.enabled ?? false);
@@ -109,11 +83,25 @@ export const AssistantSettings = () => {
})),
[models],
);
const providerOptions = useMemo(
() =>
(["hosted", "local", "custom"] satisfies AssistantProviderCategory[]).map((category) => ({
group: t(`provider.groups.${category}`),
items: assistantProviderIds
.filter((providerId) => assistantProviderPresets[providerId].category === category)
.map((providerId) => ({
value: providerId,
label: t(`provider.options.${providerId}.label`),
})),
})),
[t],
);
const selectedModel = models?.find((model) => model.id === modelId);
const destinationChanged =
configuration?.connectionConfigured === true &&
(configuration.provider !== provider || configuration.baseUrl !== baseUrl.trim().replace(/\/$/, ""));
const preset = providerPresets[provider];
const preset = assistantProviderPresets[provider];
const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0);
const hasEffectiveApiKey =
apiKey.trim().length > 0 || (!destinationChanged && configuration?.apiKeyConfigured === true && !clearApiKey);
@@ -177,12 +165,12 @@ export const AssistantSettings = () => {
const pending = isLoading || updateConnection.isPending || saveConfiguration.isPending || clearCredentials.isPending;
const changeProvider = (value: string | null) => {
if (!value || !(value in providerPresets)) return;
const nextProvider = value as Provider;
const nextPreset = providerPresets[nextProvider];
if (!value || !(value in assistantProviderPresets)) return;
const nextProvider = value as AssistantProvider;
const nextPreset = assistantProviderPresets[nextProvider];
setProvider(nextProvider);
setBaseUrl(nextPreset.baseUrl);
setModelDiscoveryPath(nextPreset.discoveryPath);
setModelDiscoveryPath(nextPreset.modelDiscoveryPath ?? "");
setEnabled(false);
};
@@ -243,10 +231,9 @@ export const AssistantSettings = () => {
description={t(`provider.options.${provider}.description`)}
value={provider}
onChange={changeProvider}
data={Object.keys(providerPresets).map((value) => ({
value,
label: t(`provider.options.${value as Provider}.label`),
}))}
data={providerOptions}
searchable
allowDeselect={false}
/>
<TextInput
label={t("baseUrl.title")}
@@ -286,6 +273,7 @@ export const AssistantSettings = () => {
)}
</Group>
<PasswordInput
aria-label={t("apiKey.title")}
value={apiKey}
onChange={(event) => {
setApiKey(event.currentTarget.value);
@@ -399,6 +387,7 @@ export const AssistantSettings = () => {
</Button>
</Group>
<Autocomplete
aria-label={t("model.title")}
value={modelId}
onChange={setModelId}
data={modelOptions}
@@ -412,6 +401,39 @@ export const AssistantSettings = () => {
}
limit={100}
/>
{discoveryError && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("model.discoveryFailed")}>
{discoveryError.message}
</Alert>
)}
{selectedModel && (
<Card withBorder padding="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={600}>{selectedModel.name}</Text>
{selectedModel.description && (
<Text size="sm" c="dimmed" lineClamp={2}>
{selectedModel.description}
</Text>
)}
</div>
<Group gap="xs" justify="flex-end">
{selectedModel.contextLength && (
<Badge variant="light">
{t("model.context", { count: selectedModel.contextLength.toLocaleString() })}
</Badge>
)}
<Badge
variant="light"
color={selectedModel.toolSupport === "confirmed" ? "green" : "gray"}
leftSection={<IconTool size={12} />}
>
{selectedModel.toolSupport === "confirmed" ? t("model.toolsConfirmed") : t("model.toolsUnknown")}
</Badge>
</Group>
</Group>
</Card>
)}
</Stack>
<Switch
@@ -14,6 +14,7 @@ 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 } from "@homarr/definitions";
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
@@ -134,7 +135,7 @@ export async function POST(request: Request) {
const configuration = await db.query.assistantConfigurations.findFirst({
where: eq(assistantConfigurations.id, "default"),
});
const requiresApiKey = configuration?.provider === "openrouter" || configuration?.provider === "openai";
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
}
@@ -79,6 +79,15 @@
overflow-wrap: anywhere;
}
.messageError {
margin-top: 0.5rem;
padding: 0.65rem 0.75rem;
border-radius: var(--mantine-radius-md);
color: var(--mantine-color-red-text);
background: var(--mantine-color-red-light);
font-size: var(--mantine-font-size-sm);
}
.tool {
margin-block: 0.5rem;
padding: 0.7rem 0.8rem;
@@ -105,10 +114,9 @@
align-items: flex-end;
gap: 0.5rem;
padding: 0.45rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: 1rem;
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-lg);
background: var(--mantine-color-default);
box-shadow: var(--mantine-shadow-sm);
pointer-events: auto;
}
@@ -130,9 +138,26 @@
min-height: 60vh;
align-items: center;
justify-content: center;
padding: 3rem 1rem 8rem;
text-align: center;
}
.suggestions {
display: grid;
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.suggestion {
height: auto;
min-height: 3.25rem;
justify-content: flex-start;
padding: 0.7rem 0.9rem;
text-align: start;
white-space: normal;
}
.mobileHistoryButton,
.historyClose {
display: none;
@@ -173,4 +198,18 @@
.historyClose {
display: inline-flex;
}
.suggestions {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.history {
transition: none;
}
.viewport {
scroll-behavior: auto;
}
}
@@ -15,10 +15,13 @@ import {
import { ActionIcon, Box, Button, Drawer, Group, ScrollArea, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import {
IconActivityHeartbeat,
IconApps,
IconArchive,
IconArrowUp,
IconCheck,
IconCopy,
IconCommand,
IconMenu2,
IconHistory,
IconPlayerStop,
@@ -26,6 +29,7 @@ import {
IconRefresh,
IconRestore,
IconRobot,
IconSearch,
IconTrash,
IconX,
} from "@tabler/icons-react";
@@ -143,6 +147,15 @@ const UserMessage = () => (
</MessagePrimitive.Root>
);
const RuntimeError = () => {
const t = useScopedI18n("common.assistant");
return (
<MessagePrimitive.Error>
<Box className={classes.messageError}>{t("responseError")}</Box>
</MessagePrimitive.Error>
);
};
const AssistantMessage = () => (
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
<MessagePrimitive.Parts
@@ -151,6 +164,7 @@ const AssistantMessage = () => (
tools: { Fallback: ToolPart },
}}
/>
<RuntimeError />
<MessageActions />
</MessagePrimitive.Root>
);
@@ -236,14 +250,14 @@ const ThreadHistory = ({ opened, onClose }: { opened: boolean; onClose: () => vo
</Group>
<Group gap="xs" px="xs" mt="xs">
<IconHistory size={14} />
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
<Text size="sm" fw={600} c="dimmed">
{t("conversations")}
</Text>
</Group>
<ScrollArea flex={1} type="auto">
<Stack gap={3}>
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
<Text size="xs" fw={700} c="dimmed" tt="uppercase" px="xs" mt="sm">
<Text size="sm" fw={600} c="dimmed" px="xs" mt="sm">
{t("archived")}
</Text>
<ThreadListPrimitive.Items archived components={{ ThreadListItem: ArchivedThreadListItem }} />
@@ -258,16 +272,44 @@ const EmptyThread = () => {
return (
<ThreadPrimitive.Empty>
<Box className={classes.empty}>
<Stack align="center" gap="sm" maw={340}>
<ThemeIcon size={52} radius="xl" variant="light" color="red">
<IconRobot size={27} />
</ThemeIcon>
<Text size="xl" fw={700}>
{t("emptyTitle")}
</Text>
<Text size="sm" c="dimmed">
{t("emptyDescription")}
</Text>
<Stack align="center" gap="lg" maw={560} w="100%">
<Stack align="center" gap="xs" maw={430}>
<ThemeIcon size={52} radius="xl" variant="light" color="red">
<IconRobot size={27} />
</ThemeIcon>
<Text size="xl" fw={700}>
{t("emptyTitle")}
</Text>
<Text size="sm" c="dimmed">
{t("emptyDescription")}
</Text>
</Stack>
<Box className={classes.suggestions}>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send asChild>
<Button
variant="default"
className={classes.suggestion}
leftSection={<IconActivityHeartbeat size={18} />}
>
{t("suggestions.health.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
{t("suggestions.explore.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
{t("suggestions.media.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.command.prompt")} send asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconCommand size={18} />}>
{t("suggestions.command.label")}
</Button>
</ThreadPrimitive.Suggestion>
</Box>
</Stack>
</Box>
</ThreadPrimitive.Empty>
+35 -9
View File
@@ -7,12 +7,13 @@ import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { and, desc, eq, inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { assistantConfigurations, assistantMessages, assistantThreads } from "@homarr/db/schema";
import { assistantProviderIds, assistantProviderPresets, assistantProviderRequiresApiKey } from "@homarr/definitions";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const configurationId = "default";
const providerSchema = z.enum(["openrouter", "openai", "ollama", "lm-studio", "custom"]);
const providerSchema = z.enum(assistantProviderIds);
const modelSchema = z.object({
id: z.string(),
@@ -27,12 +28,16 @@ const modelSchema = z.object({
type CompatibleModel = {
id?: unknown;
name?: unknown;
display_name?: unknown;
model?: unknown;
description?: unknown;
context_length?: unknown;
max_context_length?: unknown;
max_input_tokens?: unknown;
supported_parameters?: unknown;
architecture?: { output_modalities?: unknown };
pricing?: { prompt?: unknown; completion?: unknown };
capabilities?: { function_calling?: unknown };
};
type AssistantConfiguration = typeof assistantConfigurations.$inferSelect;
@@ -108,7 +113,13 @@ const decryptCustomHeaders = (encryptedHeaders: `${string}.${string}` | null) =>
const getProviderHeaders = (configuration: AssistantConfiguration) => {
const headers: Record<string, string> = {};
if (configuration.encryptedApiKey) {
headers.Authorization = `Bearer ${decryptSecret(configuration.encryptedApiKey)}`;
const apiKey = decryptSecret(configuration.encryptedApiKey);
if (assistantProviderPresets[configuration.provider].discoveryAuthentication === "anthropic") {
headers["X-Api-Key"] = apiKey;
headers["anthropic-version"] = "2023-06-01";
} else {
headers.Authorization = `Bearer ${apiKey}`;
}
}
if (configuration.provider === "openrouter") {
headers["HTTP-Referer"] ??= "https://homarr.dev";
@@ -149,13 +160,13 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
});
}
let body: { data?: CompatibleModel[]; models?: CompatibleModel[] };
let body: { data?: CompatibleModel[]; models?: CompatibleModel[] } | CompatibleModel[];
try {
body = (await response.json()) as typeof body;
} catch {
throw new TRPCError({ code: "BAD_GATEWAY", message: "The model endpoint returned invalid JSON." });
}
const models = body.data ?? body.models ?? [];
const models = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
return models
.filter((model) => {
if (configuration.provider !== "openrouter") return true;
@@ -168,16 +179,31 @@ 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 [];
const name = typeof model.name === "string" ? model.name : id;
const name =
typeof model.name === "string" ? model.name : typeof model.display_name === "string" ? model.display_name : id;
const contextLength =
typeof model.context_length === "number"
? model.context_length
: typeof model.max_context_length === "number"
? model.max_context_length
: typeof model.max_input_tokens === "number"
? model.max_input_tokens
: null;
const toolSupport =
configuration.provider === "openrouter" ||
model.capabilities?.function_calling === true ||
configuration.provider === "anthropic"
? ("confirmed" as const)
: ("unknown" as const);
return [
{
id,
name,
description: typeof model.description === "string" ? model.description : null,
contextLength: typeof model.context_length === "number" ? model.context_length : null,
contextLength,
promptPrice: typeof model.pricing?.prompt === "string" ? model.pricing.prompt : null,
completionPrice: typeof model.pricing?.completion === "string" ? model.pricing.completion : null,
toolSupport: configuration.provider === "openrouter" ? ("confirmed" as const) : ("unknown" as const),
toolSupport,
},
];
})
@@ -204,7 +230,7 @@ export const assistantRouter = createTRPCRouter({
})
.query(async ({ ctx }) => {
const configuration = await getConfigurationAsync(ctx.db);
const requiresApiKey = configuration?.provider === "openrouter" || configuration?.provider === "openai";
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
return {
enabled: Boolean(
configuration?.enabled && configuration.modelId && (!requiresApiKey || configuration.encryptedApiKey),
@@ -318,7 +344,7 @@ export const assistantRouter = createTRPCRouter({
if (!configuration) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Save a provider connection first." });
}
const requiresApiKey = configuration.provider === "openrouter" || configuration.provider === "openai";
const requiresApiKey = assistantProviderRequiresApiKey(configuration.provider);
if (input.enabled && requiresApiKey && !configuration.encryptedApiKey) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This provider requires an API key." });
}
@@ -0,0 +1,24 @@
import { describe, expect, test } from "vitest";
import { assistantProviderIds, assistantProviderPresets, assistantProviderRequiresApiKey } from "@homarr/definitions";
describe("assistant provider presets", () => {
test("defines one complete preset for every provider", () => {
expect(Object.keys(assistantProviderPresets)).toEqual(assistantProviderIds);
expect(new Set(assistantProviderIds).size).toBe(assistantProviderIds.length);
});
test.each(assistantProviderIds.filter((provider) => provider !== "custom"))(
"%s has a valid default endpoint",
(provider) => {
expect(() => new URL(assistantProviderPresets[provider].baseUrl)).not.toThrow();
expect(assistantProviderPresets[provider].modelDiscoveryPath).toMatch(/^\/[^/]/);
},
);
test("requires credentials for hosted providers but not local or custom endpoints", () => {
for (const provider of assistantProviderIds) {
expect(assistantProviderRequiresApiKey(provider)).toBe(assistantProviderPresets[provider].category === "hosted");
}
});
});
+14 -1
View File
@@ -485,7 +485,20 @@ export const assistantConfigurations = mysqlTable("assistant_configuration", {
id: varchar({ length: 64 }).notNull().primaryKey().default("default"),
enabled: boolean().notNull().default(false),
provider: varchar({ length: 32 })
.$type<"openrouter" | "openai" | "ollama" | "lm-studio" | "custom">()
.$type<
| "openrouter"
| "openai"
| "anthropic"
| "google-gemini"
| "xai"
| "groq"
| "mistral"
| "deepseek"
| "together"
| "ollama"
| "lm-studio"
| "custom"
>()
.notNull()
.default("openrouter"),
baseUrl: varchar({ length: 2048 }).notNull().default("https://openrouter.ai/api/v1"),
+14 -1
View File
@@ -484,7 +484,20 @@ export const assistantConfigurations = pgTable("assistant_configuration", {
id: varchar({ length: 64 }).notNull().primaryKey().default("default"),
enabled: boolean().notNull().default(false),
provider: varchar({ length: 32 })
.$type<"openrouter" | "openai" | "ollama" | "lm-studio" | "custom">()
.$type<
| "openrouter"
| "openai"
| "anthropic"
| "google-gemini"
| "xai"
| "groq"
| "mistral"
| "deepseek"
| "together"
| "ollama"
| "lm-studio"
| "custom"
>()
.notNull()
.default("openrouter"),
baseUrl: varchar({ length: 2048 }).notNull().default("https://openrouter.ai/api/v1"),
+17 -1
View File
@@ -469,7 +469,23 @@ export const serverSettings = sqliteTable("serverSetting", {
export const assistantConfigurations = sqliteTable("assistant_configuration", {
id: text().notNull().primaryKey().default("default"),
enabled: int({ mode: "boolean" }).notNull().default(false),
provider: text().$type<"openrouter" | "openai" | "ollama" | "lm-studio" | "custom">().notNull().default("openrouter"),
provider: text()
.$type<
| "openrouter"
| "openai"
| "anthropic"
| "google-gemini"
| "xai"
| "groq"
| "mistral"
| "deepseek"
| "together"
| "ollama"
| "lm-studio"
| "custom"
>()
.notNull()
.default("openrouter"),
baseUrl: text().notNull().default("https://openrouter.ai/api/v1"),
modelDiscoveryPath: text().default("/models"),
encryptedApiKey: text().$type<`${string}.${string}`>(),
+115
View File
@@ -0,0 +1,115 @@
export const assistantProviderIds = [
"openrouter",
"openai",
"anthropic",
"google-gemini",
"xai",
"groq",
"mistral",
"deepseek",
"together",
"ollama",
"lm-studio",
"custom",
] as const;
export type AssistantProvider = (typeof assistantProviderIds)[number];
export type AssistantProviderCategory = "hosted" | "local" | "custom";
interface AssistantProviderPreset {
baseUrl: string;
modelDiscoveryPath: string | null;
requiresApiKey: boolean;
category: AssistantProviderCategory;
discoveryAuthentication: "bearer" | "anthropic";
}
export const assistantProviderPresets = {
openrouter: {
baseUrl: "https://openrouter.ai/api/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
},
openai: {
baseUrl: "https://api.openai.com/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
},
anthropic: {
baseUrl: "https://api.anthropic.com/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "anthropic",
},
"google-gemini": {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
modelDiscoveryPath: "/models",
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
},
xai: {
baseUrl: "https://api.x.ai/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
},
groq: {
baseUrl: "https://api.groq.com/openai/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
},
mistral: {
baseUrl: "https://api.mistral.ai/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
},
deepseek: {
baseUrl: "https://api.deepseek.com",
modelDiscoveryPath: "/models",
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
},
together: {
baseUrl: "https://api.together.xyz/v1",
modelDiscoveryPath: "/models",
requiresApiKey: true,
category: "hosted",
discoveryAuthentication: "bearer",
},
ollama: {
baseUrl: "http://localhost:11434/v1",
modelDiscoveryPath: "/models",
requiresApiKey: false,
category: "local",
discoveryAuthentication: "bearer",
},
"lm-studio": {
baseUrl: "http://localhost:1234/v1",
modelDiscoveryPath: "/models",
requiresApiKey: false,
category: "local",
discoveryAuthentication: "bearer",
},
custom: {
baseUrl: "",
modelDiscoveryPath: "/models",
requiresApiKey: false,
category: "custom",
discoveryAuthentication: "bearer",
},
} as const satisfies Record<AssistantProvider, AssistantProviderPreset>;
export const assistantProviderRequiresApiKey = (provider: AssistantProvider) =>
assistantProviderPresets[provider].requiresApiKey;
+1
View File
@@ -18,3 +18,4 @@ export * from "./hotkeys";
export * from "./docker-integration-match";
export * from "./integration-url-template";
export * from "./release-provider";
export * from "./assistant";
+57 -1
View File
@@ -1519,11 +1519,30 @@
"openHistory": "Open conversation history",
"emptyTitle": "What can I help with?",
"emptyDescription": "Ask about your Homarr setup, inspect live instance data, or approve actions when you want me to make a change.",
"suggestions": {
"health": {
"label": "Check service health",
"prompt": "Check my integrations and summarize anything that needs attention."
},
"explore": {
"label": "Explore my Homarr",
"prompt": "Show me what I can view and manage in this Homarr instance."
},
"media": {
"label": "Find media to request",
"prompt": "Help me find a movie or series to request."
},
"command": {
"label": "Open the command menu",
"prompt": "Open the Homarr command menu for me."
}
},
"composerPlaceholder": "Ask Homarr Assistant…",
"stop": "Stop response",
"send": "Send message",
"copy": "Copy response",
"regenerate": "Regenerate response",
"responseError": "The response stopped unexpectedly. Try again, or ask an administrator to verify the model endpoint.",
"scrollToLatest": "Scroll to latest",
"approvalRequired": "Approval required",
"complete": "Complete",
@@ -4863,6 +4882,11 @@
},
"provider": {
"title": "Provider",
"groups": {
"hosted": "Hosted providers",
"local": "Local providers",
"custom": "Custom"
},
"options": {
"openrouter": {
"label": "OpenRouter",
@@ -4872,6 +4896,34 @@
"label": "OpenAI",
"description": "OpenAI Chat Completions through the official API endpoint."
},
"anthropic": {
"label": "Anthropic Claude",
"description": "Claude through Anthropic's OpenAI SDK compatibility endpoint."
},
"google-gemini": {
"label": "Google Gemini",
"description": "Gemini through Google's OpenAI-compatible API endpoint."
},
"xai": {
"label": "xAI",
"description": "Grok models through xAI's OpenAI-compatible API."
},
"groq": {
"label": "Groq",
"description": "Fast hosted inference through Groq's OpenAI-compatible API."
},
"mistral": {
"label": "Mistral AI",
"description": "Mistral models through the official API."
},
"deepseek": {
"label": "DeepSeek",
"description": "DeepSeek models through its OpenAI-compatible API."
},
"together": {
"label": "Together AI",
"description": "Hosted open models through Together's OpenAI-compatible API."
},
"ollama": {
"label": "Ollama",
"description": "A local or network Ollama server using its OpenAI-compatible API."
@@ -4926,7 +4978,11 @@
"discoveryPathDescription": "Appended to the API base URL. Leave blank to disable discovery and use a manual model ID.",
"manual": "Enter a model ID manually or save the connection to discover models.",
"manualFallback": "Discovery is unavailable. You can still enter a model ID manually.",
"discovered": "Discovered {count} models. Tool support depends on the selected endpoint and model."
"discovered": "Discovered {count} models. Tool support depends on the selected endpoint and model.",
"discoveryFailed": "Model discovery did not complete",
"context": "{count} token context",
"toolsConfirmed": "Tools advertised",
"toolsUnknown": "Verify tool support"
},
"enabled": {
"label": "Enable Homarr Assistant",