From af75bcd741554b61a728795711d6f2ee8a8e29a1 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 30 Jul 2026 16:00:09 +0200 Subject: [PATCH] fix: send provider model ids --- apps/docs/docs/management/assistant.mdx | 6 +- .../_components/assistant-configuration.tsx | 68 +++++++++++++------ .../chat/assistant-stream-error.spec.ts | 34 ++++++++++ .../assistant/chat/assistant-stream-error.ts | 61 +++++++++++++++++ .../src/app/api/assistant/chat/route.ts | 11 +-- packages/api/src/router/assistant.ts | 19 +++++- .../api/src/router/test/assistant.spec.ts | 39 +++++++++++ .../api/src/test/assistant-providers.spec.ts | 19 +++++- packages/definitions/src/assistant.ts | 11 +++ 9 files changed, 235 insertions(+), 33 deletions(-) create mode 100644 apps/nextjs/src/app/api/assistant/chat/assistant-stream-error.spec.ts create mode 100644 apps/nextjs/src/app/api/assistant/chat/assistant-stream-error.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 670e33ddb..875d1fb07 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -33,11 +33,13 @@ preset connects any other service that implements the OpenAI Chat Completions AP 3. Add an API key if the service requires bearer authentication. 4. Add any provider-specific authentication or routing headers. 5. Save the provider connection. -6. Refresh the model list or enter an exact model ID manually. +6. Refresh the model list and select a discovered model. Homarr displays the friendly model name but + stores and sends the provider's exact model ID. 7. Enable the assistant and save the configuration. The model discovery path defaults to `/models` and is appended to the API base URL. Leave it blank when -the service does not expose model discovery. A manual model ID can still be used. +the service does not expose model discovery. A manual model ID can still be used when discovery is +disabled, unavailable, or returns no models. API keys and additional header values are encrypted in the Homarr database. They are decrypted only on the server for discovery and model requests, and they are never returned to the browser. Changing the diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx index e676311c1..bfb3841f7 100644 --- a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx @@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react"; import { ActionIcon, Alert, - Autocomplete, Badge, Button, Card, @@ -33,7 +32,12 @@ import { } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import { assistantProviderIds, assistantProviderPresets } from "@homarr/definitions"; +import { + assistantProviderIds, + assistantProviderPresets, + getAssistantModelOptionLabel, + resolveAssistantModelId, +} from "@homarr/definitions"; import type { AssistantProvider, AssistantProviderCategory } from "@homarr/definitions"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; @@ -97,7 +101,7 @@ export const AssistantConfiguration = () => { () => (models ?? []).map((model) => ({ value: model.id, - label: model.name === model.id ? model.id : `${model.name} (${model.id})`, + label: getAssistantModelOptionLabel(model), })), [models], ); @@ -115,6 +119,15 @@ export const AssistantConfiguration = () => { [t], ); const selectedModel = connectionChanged ? undefined : models?.find((model) => model.id === modelId); + const hasDiscoveredModels = !connectionChanged && (models?.length ?? 0) > 0; + + useEffect(() => { + if (!models || models.length === 0) return; + const resolvedModelId = resolveAssistantModelId(models, modelId); + if (resolvedModelId && resolvedModelId !== modelId) { + setModelId(resolvedModelId); + } + }, [modelId, models]); const preset = assistantProviderPresets[provider]; const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0); @@ -443,26 +456,37 @@ export const AssistantConfiguration = () => { {t("model.refresh")} - setModelId(value ?? "")} + data={modelControlsDisabled ? [] : modelOptions} + disabled={modelControlsDisabled} + placeholder={t("model.placeholder")} + description={t("model.discovered", { count: models?.length ?? 0 })} + searchable + allowDeselect={false} + limit={100} + /> + ) : ( + setModelId(event.currentTarget.value)} + disabled={modelControlsDisabled} + placeholder={t("model.placeholder")} + description={ + connectionPending + ? t("model.saveConnectionFirst") + : isDiscovering + ? t("model.discovering") + : discoveryError || models?.length === 0 + ? t("model.manualFallback") : t("model.manual") - } - limit={100} - /> + } + /> + )} {discoveryError && !connectionPending && ( } title={t("model.discoveryFailed")}> {discoveryError.message} diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-stream-error.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-stream-error.spec.ts new file mode 100644 index 000000000..2aca5302d --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-stream-error.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; + +import { getAssistantStreamErrorMessage } from "./assistant-stream-error"; + +describe("assistant stream errors", () => { + test("explains invalid provider model identifiers", () => { + expect( + getAssistantStreamErrorMessage({ + statusCode: 400, + responseBody: JSON.stringify({ + error: { + message: "DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro) is not a valid model ID", + }, + }), + }), + ).toBe("The provider rejected the selected model. Ask an administrator to select a valid model ID."); + }); + + test.each([ + [401, "The provider rejected the configured credentials. Ask an administrator to update the API key."], + [402, "The provider account has insufficient credits for this request."], + [404, "The selected model or chat endpoint was not found. Ask an administrator to verify the model and API URL."], + [429, "The model endpoint is rate limited. Wait a moment and try again."], + [503, "The model provider is temporarily unavailable. Try again later."], + ])("maps provider status %s to an actionable message", (statusCode, expected) => { + expect(getAssistantStreamErrorMessage({ statusCode })).toBe(expected); + }); + + test("recognizes nested timeout errors", () => { + expect(getAssistantStreamErrorMessage(new Error("request failed", { cause: new Error("timed out") }))).toBe( + "The model endpoint took too long to respond. Try again.", + ); + }); +}); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-stream-error.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-stream-error.ts new file mode 100644 index 000000000..fff184da8 --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-stream-error.ts @@ -0,0 +1,61 @@ +const asRecord = (value: unknown): Record | null => + typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : null; + +const getStatusCode = (error: unknown): number | undefined => { + const record = asRecord(error); + if (!record) return undefined; + const statusCode = + typeof record.statusCode === "number" + ? record.statusCode + : typeof record.status === "number" + ? record.status + : undefined; + if (statusCode !== undefined) return statusCode; + return getStatusCode(record.cause) ?? getStatusCode(record.response); +}; + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return `${error.message} ${getErrorMessage(error.cause)}`.trim(); + } + const record = asRecord(error); + if (!record) return typeof error === "string" ? error : ""; + const responseBody = typeof record.responseBody === "string" ? record.responseBody : ""; + const message = typeof record.message === "string" ? record.message : ""; + return `${message} ${responseBody} ${getErrorMessage(record.cause)}`.trim(); +}; + +export const getAssistantStreamErrorMessage = (error: unknown) => { + const statusCode = getStatusCode(error); + const message = getErrorMessage(error); + + if ( + /\bmodel(?:\s+id)?\b/iu.test(message) && + /\b(invalid|unknown|unavailable|not found|not a valid)\b/iu.test(message) + ) { + return "The provider rejected the selected model. Ask an administrator to select a valid model ID."; + } + if (statusCode === 400) { + return "The provider rejected the request. The selected model may not support the requested input or tools."; + } + if (statusCode === 401 || statusCode === 403) { + return "The provider rejected the configured credentials. Ask an administrator to update the API key."; + } + if (statusCode === 402) { + return "The provider account has insufficient credits for this request."; + } + if (statusCode === 404) { + return "The selected model or chat endpoint was not found. Ask an administrator to verify the model and API URL."; + } + if (statusCode === 408 || statusCode === 504 || /\b(timeout|timed out)\b/iu.test(message)) { + return "The model endpoint took too long to respond. Try again."; + } + if (statusCode === 429) { + return "The model endpoint is rate limited. Wait a moment and try again."; + } + if (statusCode !== undefined && statusCode >= 500) { + return "The model provider is temporarily unavailable. Try again later."; + } + + return "The model endpoint stopped the response. Try again, or ask an administrator to verify its URL, model, and credentials."; +}; diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index 8dbc590f1..dce7a4379 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -27,6 +27,7 @@ import type { import { extractMcpTools } from "../../mcp/_extract-tools"; import { getRequestedMentionIds, sanitizeAttachmentFilename } from "./assistant-chat-input"; +import { getAssistantStreamErrorMessage } from "./assistant-stream-error"; export const maxDuration = 60; @@ -35,8 +36,6 @@ const getToolApprovalSecret = () => Buffer.from( hkdfSync("sha256", Buffer.from(env.SECRET_ENCRYPTION_KEY, "hex"), "", "assistant-tool-approval", 32), ).toString("base64url"); -const safeStreamError = - "The model endpoint stopped the response. Try again, or ask an administrator to verify its URL, model, and credentials."; const allowedAttachmentMediaTypes = new Set([ "image/gif", "image/jpeg", @@ -314,8 +313,6 @@ export async function POST(request: Request) { if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) { return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 }); } - const modelId = configuration.modelId; - const thread = await db.query.assistantThreads.findFirst({ where: and(eq(assistantThreads.id, parsed.data.id), eq(assistantThreads.userId, session.user.id)), }); @@ -330,6 +327,7 @@ export async function POST(request: Request) { getSelectedModelDetailsAsync(configuration).catch(() => null), getMentionContextAsync(context, parsed.data.messages), ]); + const modelId = selectedModel?.id ?? configuration.modelId; if ( selectedModel && selectedModel.inputModalities.length > 0 && @@ -488,6 +486,9 @@ export async function POST(request: Request) { }, onError: ({ error }) => { logger.error("Assistant response stream failed", { + requestId, + provider: configuration.provider, + modelId, error: error instanceof Error ? error.message : String(error), }); }, @@ -555,7 +556,7 @@ export async function POST(request: Request) { }, }; }, - onError: () => safeStreamError, + onError: getAssistantStreamErrorMessage, }); } catch (error) { logger.error("Assistant response could not start", { diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 635423fb2..ccff2d849 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -18,7 +18,12 @@ import { integrationUserPermissions, items, } from "@homarr/db/schema"; -import { assistantProviderIds, assistantProviderPresets, assistantProviderRequiresApiKey } from "@homarr/definitions"; +import { + assistantProviderIds, + assistantProviderPresets, + assistantProviderRequiresApiKey, + resolveAssistantModelId, +} from "@homarr/definitions"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc"; import type { createTRPCContext } from "../trpc"; @@ -256,7 +261,9 @@ export const getSelectedModelDetailsAsync = async (configuration: AssistantConfi const cached = selectedModelCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) return cached.value; - const value = (await fetchModelsAsync(configuration)).find((model) => model.id === configuration.modelId) ?? null; + const models = await fetchModelsAsync(configuration); + const resolvedModelId = resolveAssistantModelId(models, configuration.modelId); + const value = models.find((model) => model.id === resolvedModelId) ?? null; selectedModelCache.clear(); selectedModelCache.set(cacheKey, { expiresAt: Date.now() + 10 * 60_000, value }); return value; @@ -547,9 +554,15 @@ export const assistantRouter = createTRPCRouter({ if (input.enabled && requiresApiKey && !configuration.encryptedApiKey) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This provider requires an API key." }); } + const discoveredModels = configuration.modelDiscoveryPath + ? await fetchModelsAsync(configuration).catch(() => null) + : null; + const modelId = discoveredModels + ? (resolveAssistantModelId(discoveredModels, input.modelId) ?? input.modelId) + : input.modelId; await ctx.db .update(assistantConfigurations) - .set({ enabled: input.enabled, modelId: input.modelId, updatedAt: new Date() }) + .set({ enabled: input.enabled, modelId, updatedAt: new Date() }) .where(eq(assistantConfigurations.id, configurationId)); }), diff --git a/packages/api/src/router/test/assistant.spec.ts b/packages/api/src/router/test/assistant.spec.ts index db0e894d4..409f9c37d 100644 --- a/packages/api/src/router/test/assistant.spec.ts +++ b/packages/api/src/router/test/assistant.spec.ts @@ -5,6 +5,7 @@ import { parse } from "superjson"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; +import { eq } from "@homarr/db"; import { apps, assistantConfigurations, @@ -101,6 +102,44 @@ describe("assistantRouter.updateConnection", () => { }); }); +describe("assistantRouter.updateConfiguration", () => { + test("normalizes legacy model labels to the discovered provider ID", async () => { + const db = await createConfiguredAssistantAsync(); + await db + .update(assistantConfigurations) + .set({ encryptedHeaders: null }) + .where(eq(assistantConfigurations.id, "default")); + const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json({ + data: [ + { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek: DeepSeek V4 Pro", + supported_parameters: ["tools"], + architecture: { output_modalities: ["text"] }, + }, + ], + }), + ), + ); + + try { + await caller.updateConfiguration({ + enabled: true, + modelId: "DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro)", + }); + } finally { + vi.unstubAllGlobals(); + } + + const [configuration] = await db.select().from(assistantConfigurations); + expect(configuration?.modelId).toBe("deepseek/deepseek-v4-pro"); + }); +}); + describe("assistant conversation features", () => { test("returns permission-checked entities for composer mentions", async () => { const db = await createConfiguredAssistantAsync(); diff --git a/packages/api/src/test/assistant-providers.spec.ts b/packages/api/src/test/assistant-providers.spec.ts index 78e1b5080..a18882604 100644 --- a/packages/api/src/test/assistant-providers.spec.ts +++ b/packages/api/src/test/assistant-providers.spec.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "vitest"; -import { assistantProviderIds, assistantProviderPresets, assistantProviderRequiresApiKey } from "@homarr/definitions"; +import { + assistantProviderIds, + assistantProviderPresets, + assistantProviderRequiresApiKey, + getAssistantModelOptionLabel, + resolveAssistantModelId, +} from "@homarr/definitions"; describe("assistant provider presets", () => { test("defines one complete preset for every provider", () => { @@ -30,4 +36,15 @@ describe("assistant provider presets", () => { expect(() => new URL(iconUrl ?? "")).not.toThrow(); }, ); + + test("keeps the display label separate from the provider model ID", () => { + const model = { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek: DeepSeek V4 Pro", + }; + + expect(getAssistantModelOptionLabel(model)).toBe("DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro)"); + expect(resolveAssistantModelId([model], model.id)).toBe(model.id); + expect(resolveAssistantModelId([model], getAssistantModelOptionLabel(model))).toBe(model.id); + }); }); diff --git a/packages/definitions/src/assistant.ts b/packages/definitions/src/assistant.ts index 1bafb7df0..acb3d1d78 100644 --- a/packages/definitions/src/assistant.ts +++ b/packages/definitions/src/assistant.ts @@ -16,6 +16,17 @@ export const assistantProviderIds = [ export type AssistantProvider = (typeof assistantProviderIds)[number]; export type AssistantProviderCategory = "hosted" | "local" | "custom"; +export interface AssistantModelOption { + id: string; + name: string; +} + +export const getAssistantModelOptionLabel = (model: AssistantModelOption) => + model.name === model.id ? model.id : `${model.name} (${model.id})`; + +export const resolveAssistantModelId = (models: AssistantModelOption[], value: string) => + models.find((model) => model.id === value || getAssistantModelOptionLabel(model) === value)?.id ?? null; + const providerIconBaseUrl = "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons"; interface AssistantProviderPreset {