fix: send provider model ids

This commit is contained in:
ajnart committed 2026-07-30 16:00:09 +02:00
1 parent 6bd331bc73
commit af75bcd741
9 files changed
+235 -33

No files matched your search

+4 -2
View File
@@ -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
@@ -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")}
</Button>
</Group>
<Autocomplete
aria-label={t("model.title")}
value={modelId}
onChange={setModelId}
data={modelControlsDisabled ? [] : modelOptions}
disabled={modelControlsDisabled}
placeholder={t("model.placeholder")}
description={
connectionPending
? t("model.saveConnectionFirst")
: isDiscovering
? t("model.discovering")
: discoveryError
? t("model.manualFallback")
: models
? t("model.discovered", { count: models.length })
{hasDiscoveredModels ? (
<Select
aria-label={t("model.title")}
value={modelId || null}
onChange={(value) => setModelId(value ?? "")}
data={modelControlsDisabled ? [] : modelOptions}
disabled={modelControlsDisabled}
placeholder={t("model.placeholder")}
description={t("model.discovered", { count: models?.length ?? 0 })}
searchable
allowDeselect={false}
limit={100}
/>
) : (
<TextInput
aria-label={t("model.title")}
value={modelId}
onChange={(event) => 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 && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("model.discoveryFailed")}>
{discoveryError.message}
@@ -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.",
);
});
});
@@ -0,0 +1,61 @@
const asRecord = (value: unknown): Record<string, unknown> | null =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : 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.";
};
@@ -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", {
+16 -3
View File
@@ -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));
}),
@@ -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();
@@ -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);
});
});
+11
View File
@@ -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 {