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
+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", {
|
||||
|
||||
Reference in new issue
Block a user