diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/assistant-settings.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/assistant-settings.tsx index 76f81f91a..3570ceb2c 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/assistant-settings.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/assistant-settings.tsx @@ -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("openrouter"); - const [baseUrl, setBaseUrl] = useState(providerPresets.openrouter.baseUrl); + const [provider, setProvider] = useState("openrouter"); + const [baseUrl, setBaseUrl] = useState(assistantProviderPresets.openrouter.baseUrl); const [modelDiscoveryPath, setModelDiscoveryPath] = useState("/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} /> { )} { setApiKey(event.currentTarget.value); @@ -399,6 +387,7 @@ export const AssistantSettings = () => { { } limit={100} /> + {discoveryError && ( + } title={t("model.discoveryFailed")}> + {discoveryError.message} + + )} + {selectedModel && ( + + +
+ {selectedModel.name} + {selectedModel.description && ( + + {selectedModel.description} + + )} +
+ + {selectedModel.contextLength && ( + + {t("model.context", { count: selectedModel.contextLength.toLocaleString() })} + + )} + } + > + {selectedModel.toolSupport === "confirmed" ? t("model.toolsConfirmed") : t("model.toolsUnknown")} + + +
+
+ )} ( ); +const RuntimeError = () => { + const t = useScopedI18n("common.assistant"); + return ( + + {t("responseError")} + + ); +}; + const AssistantMessage = () => ( ( tools: { Fallback: ToolPart }, }} /> + ); @@ -236,14 +250,14 @@ const ThreadHistory = ({ opened, onClose }: { opened: boolean; onClose: () => vo - + {t("conversations")} - + {t("archived")} @@ -258,16 +272,44 @@ const EmptyThread = () => { return ( - - - - - - {t("emptyTitle")} - - - {t("emptyDescription")} - + + + + + + + {t("emptyTitle")} + + + {t("emptyDescription")} + + + + + + + + + + + + + + + + diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 990cf3c18..1cc2adfcb 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -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 = {}; 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." }); } diff --git a/packages/api/src/test/assistant-providers.spec.ts b/packages/api/src/test/assistant-providers.spec.ts new file mode 100644 index 000000000..2858cd9d9 --- /dev/null +++ b/packages/api/src/test/assistant-providers.spec.ts @@ -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"); + } + }); +}); diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 760a03098..550849069 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -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"), diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts index 199244a0e..e1776ca2b 100644 --- a/packages/db/schema/postgresql.ts +++ b/packages/db/schema/postgresql.ts @@ -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"), diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index 76b2dba91..df94ccca0 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -468,7 +468,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}`>(), diff --git a/packages/definitions/src/assistant.ts b/packages/definitions/src/assistant.ts new file mode 100644 index 000000000..1d926d207 --- /dev/null +++ b/packages/definitions/src/assistant.ts @@ -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; + +export const assistantProviderRequiresApiKey = (provider: AssistantProvider) => + assistantProviderPresets[provider].requiresApiKey; diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts index 94b392ee7..8e0f21b13 100644 --- a/packages/definitions/src/index.ts +++ b/packages/definitions/src/index.ts @@ -18,3 +18,4 @@ export * from "./hotkeys"; export * from "./docker-integration-match"; export * from "./integration-url-template"; export * from "./release-provider"; +export * from "./assistant"; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 692e9ebfb..864bdf246 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1995,11 +1995,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", @@ -5852,6 +5871,11 @@ }, "provider": { "title": "Provider", + "groups": { + "hosted": "Hosted providers", + "local": "Local providers", + "custom": "Custom" + }, "options": { "openrouter": { "label": "OpenRouter", @@ -5861,6 +5885,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." @@ -5915,7 +5967,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",