fix: send provider model ids
This commit is contained in:
1 parent
6bd331bc73
commit
af75bcd741
9 files changed
+235
-33
No files matched your search
@@ -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
|
||||
|
||||
+46
-22
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user