mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
feat(providers): custom icon URL for compatible provider nodes (#5815)
Integrated into release/v3.8.44 — custom icon URL for compatible provider nodes (DB migration 113 + nodes.ts + Zod schema + API routes + catalog + ProviderIcon UI). Re-cut onto the release tip (branch was fossilized ~13 real files); reconciled icon_url into the release's evolved nodes.ts/routes via 3-way. Validated: 14 backend + 5 frontend(vitest) + 24 page-utils tests green, typecheck:core 0, provider-consistency OK, file-size/env-doc-sync pass. UNSTABLE red is the inherited environmental setup-claude base-red.
This commit is contained in:
committed by
GitHub
parent
0965c54245
commit
a49d34bf49
@@ -3,6 +3,7 @@
|
||||
// Phase 1t.2 extraction — Issue #3501
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { getApiLabel, getApiPath } from "../providerPageHelpers";
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
@@ -11,6 +12,8 @@ interface ProviderNode {
|
||||
apiType?: string;
|
||||
chatPath?: string;
|
||||
prefix?: string;
|
||||
/** Optional operator-supplied remote icon URL (#2166). */
|
||||
iconUrl?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -42,24 +45,35 @@ export default function CompatibleNodeCard({
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isCcCompatible
|
||||
? t("ccCompatibleDetailsTitle")
|
||||
: isAnthropicCompatible
|
||||
? t("anthropicCompatibleDetails")
|
||||
: t("openaiCompatibleDetails")}
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getApiLabel(t, isAnthropicProtocolCompatible, providerNode?.apiType)} ·{" "}
|
||||
{(providerNode.baseUrl || "").replace(/\/$/, "")}/
|
||||
{getApiPath(
|
||||
isCcCompatible,
|
||||
isAnthropicCompatible,
|
||||
providerNode?.apiType,
|
||||
providerNode?.chatPath
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{providerNode.iconUrl && (
|
||||
<ProviderIcon
|
||||
providerId={providerId}
|
||||
src={providerNode.iconUrl}
|
||||
size={32}
|
||||
className="shrink-0 rounded-lg"
|
||||
fallbackText={isCcCompatible ? "CC" : isAnthropicCompatible ? "AC" : "OC"}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isCcCompatible
|
||||
? t("ccCompatibleDetailsTitle")
|
||||
: isAnthropicCompatible
|
||||
? t("anthropicCompatibleDetails")
|
||||
: t("openaiCompatibleDetails")}
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getApiLabel(t, isAnthropicProtocolCompatible, providerNode?.apiType)} ·{" "}
|
||||
{(providerNode.baseUrl || "").replace(/\/$/, "")}/
|
||||
{getApiPath(
|
||||
isCcCompatible,
|
||||
isAnthropicCompatible,
|
||||
providerNode?.apiType,
|
||||
providerNode?.chatPath
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" icon="add" onClick={() => gateConnectionFlow(openApiKeyAddFlow)}>
|
||||
|
||||
@@ -12,6 +12,10 @@ interface ProviderInfo {
|
||||
website?: string;
|
||||
color: string;
|
||||
apiType?: string;
|
||||
/** Optional operator-supplied remote icon URL (#2166) for compatible provider nodes. */
|
||||
iconUrl?: string;
|
||||
/** Short text-badge fallback (e.g. "OC"/"AC"/"CC") shown if `iconUrl` fails to load. */
|
||||
textIcon?: string;
|
||||
}
|
||||
|
||||
interface ProviderPageHeaderProps {
|
||||
@@ -56,6 +60,10 @@ export default function ProviderPageHeader({
|
||||
)}
|
||||
size={48}
|
||||
type="color"
|
||||
src={providerInfo.iconUrl}
|
||||
alt={providerInfo.name}
|
||||
fallbackText={providerInfo.textIcon}
|
||||
fallbackColor={providerInfo.color}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface EditCompatibleNodeModalNode {
|
||||
baseUrl?: string;
|
||||
chatPath?: string;
|
||||
modelsPath?: string;
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
interface EditCompatibleNodeModalProps {
|
||||
@@ -38,6 +39,7 @@ export default function EditCompatibleNodeModal({
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
chatPath: "",
|
||||
modelsPath: "",
|
||||
iconUrl: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [checkKey, setCheckKey] = useState("");
|
||||
@@ -63,6 +65,7 @@ export default function EditCompatibleNodeModal({
|
||||
: "https://api.openai.com/v1"),
|
||||
chatPath: node.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""),
|
||||
modelsPath: isCcCompatible ? "" : node.modelsPath || "",
|
||||
iconUrl: node.iconUrl || "",
|
||||
});
|
||||
setShowAdvanced(
|
||||
!!(
|
||||
@@ -93,6 +96,7 @@ export default function EditCompatibleNodeModal({
|
||||
baseUrl: formData.baseUrl,
|
||||
chatPath: formData.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""),
|
||||
modelsPath: isCcCompatible ? "" : formData.modelsPath,
|
||||
iconUrl: formData.iconUrl.trim(),
|
||||
};
|
||||
if (!isAnthropic) {
|
||||
payload.apiType = formData.apiType;
|
||||
@@ -208,6 +212,13 @@ export default function EditCompatibleNodeModal({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label={t("iconUrlLabel")}
|
||||
value={formData.iconUrl}
|
||||
onChange={(e) => setFormData({ ...formData, iconUrl: e.target.value })}
|
||||
placeholder="https://example.com/logo.png"
|
||||
hint={t("iconUrlHint")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
|
||||
@@ -23,6 +23,7 @@ interface CompatibleFormState {
|
||||
baseUrl: string;
|
||||
chatPath: string;
|
||||
modelsPath: string;
|
||||
iconUrl: string;
|
||||
}
|
||||
|
||||
const CC_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
|
||||
@@ -75,6 +76,7 @@ function createInitialForm(mode: CompatibleMode): CompatibleFormState {
|
||||
baseUrl: defaults.baseUrl,
|
||||
chatPath: defaults.chatPath,
|
||||
modelsPath: "",
|
||||
iconUrl: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,6 +183,7 @@ export default function AddCompatibleProviderModal({
|
||||
if (defaults.hasApiType) body.apiType = formData.apiType;
|
||||
if (defaults.hasModelsPath) body.modelsPath = formData.modelsPath || "";
|
||||
if (defaults.compatMode) body.compatMode = defaults.compatMode;
|
||||
body.iconUrl = formData.iconUrl.trim();
|
||||
|
||||
const res = await fetch("/api/provider-nodes", {
|
||||
method: "POST",
|
||||
@@ -276,6 +279,13 @@ export default function AddCompatibleProviderModal({
|
||||
placeholder={baseUrlPlaceholder}
|
||||
hint={baseUrlHint}
|
||||
/>
|
||||
<Input
|
||||
label={t("iconUrlLabel")}
|
||||
value={formData.iconUrl}
|
||||
onChange={(e) => setFormData({ ...formData, iconUrl: e.target.value })}
|
||||
placeholder="https://example.com/logo.png"
|
||||
hint={t("iconUrlHint")}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -56,6 +56,10 @@ interface ProviderCardProps {
|
||||
subscriptionRisk?: boolean;
|
||||
/** Declared service kinds — "llm" enables the inline Test button */
|
||||
serviceKinds?: string[];
|
||||
/** Optional operator-supplied remote icon URL (#2166) for compatible provider nodes. */
|
||||
iconUrl?: string;
|
||||
/** Short text-badge fallback (e.g. "OC"/"AC"/"CC") shown if `iconUrl` fails to load. */
|
||||
textIcon?: string;
|
||||
};
|
||||
stats: ProviderStats;
|
||||
authType?: string;
|
||||
@@ -240,7 +244,17 @@ export default function ProviderCard({
|
||||
className="size-9 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${provider.color || "#64748b"}15` }}
|
||||
>
|
||||
{staticIconPath ? (
|
||||
{provider.iconUrl ? (
|
||||
<ProviderIcon
|
||||
providerId={provider.id || providerId}
|
||||
src={provider.iconUrl}
|
||||
alt={provider.name}
|
||||
size={26}
|
||||
className="max-h-[26px] max-w-[26px] rounded-lg object-contain"
|
||||
fallbackText={provider.textIcon}
|
||||
fallbackColor={provider.color}
|
||||
/>
|
||||
) : staticIconPath ? (
|
||||
<Image
|
||||
src={staticIconPath}
|
||||
alt={provider.name}
|
||||
|
||||
@@ -35,6 +35,8 @@ export type CompatibleProviderInfo = {
|
||||
color: string;
|
||||
textIcon: string;
|
||||
apiType?: string;
|
||||
/** Optional operator-supplied remote icon URL (#2166). */
|
||||
iconUrl?: string;
|
||||
};
|
||||
|
||||
export type CompatibleProviderGroups = {
|
||||
@@ -149,7 +151,13 @@ export function buildStaticProviderEntries(
|
||||
}
|
||||
|
||||
export function buildCompatibleProviderGroups(
|
||||
providerNodes: Array<{ id: string; name?: string; type?: string; apiType?: string }>,
|
||||
providerNodes: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
apiType?: string;
|
||||
iconUrl?: string | null;
|
||||
}>,
|
||||
labels: {
|
||||
openaiCompatibleName: string;
|
||||
anthropicCompatibleName: string;
|
||||
@@ -168,6 +176,7 @@ export function buildCompatibleProviderGroups(
|
||||
color: "#10A37F",
|
||||
textIcon: "OC",
|
||||
apiType: node.apiType,
|
||||
iconUrl: node.iconUrl || undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -180,6 +189,7 @@ export function buildCompatibleProviderGroups(
|
||||
name: node.name || labels.claudeCodeCompatibleName,
|
||||
color: "#B45309",
|
||||
textIcon: "CC",
|
||||
iconUrl: node.iconUrl || undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -189,6 +199,7 @@ export function buildCompatibleProviderGroups(
|
||||
name: node.name || labels.anthropicCompatibleName,
|
||||
color: "#D97757",
|
||||
textIcon: "AC",
|
||||
iconUrl: node.iconUrl || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { name, prefix, apiType, baseUrl, chatPath, modelsPath, customHeaders } = validation.data;
|
||||
const { name, prefix, apiType, baseUrl, chatPath, modelsPath, customHeaders, iconUrl } =
|
||||
validation.data;
|
||||
const node: any = await getProviderNodeById(id);
|
||||
|
||||
if (!node) {
|
||||
@@ -93,6 +94,9 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
baseUrl: sanitizedBaseUrl,
|
||||
chatPath: chatPath || null,
|
||||
modelsPath: isClaudeCodeCompatibleProvider(id) ? null : modelsPath || null,
|
||||
// #2166: explicit null (not omission) so an empty submission clears a
|
||||
// previously stored custom icon.
|
||||
iconUrl: iconUrl?.trim() || null,
|
||||
customHeaders: customHeaders || null,
|
||||
};
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ export async function POST(request) {
|
||||
chatPath,
|
||||
modelsPath,
|
||||
customHeaders,
|
||||
iconUrl,
|
||||
} = validation.data;
|
||||
|
||||
// Determine type
|
||||
@@ -98,6 +99,7 @@ export async function POST(request) {
|
||||
name: name.trim(),
|
||||
chatPath: chatPath || null,
|
||||
modelsPath: modelsPath || null,
|
||||
iconUrl: iconUrl?.trim() || null,
|
||||
customHeaders: customHeaders || null,
|
||||
});
|
||||
return NextResponse.json({ node }, { status: 201 });
|
||||
@@ -127,6 +129,7 @@ export async function POST(request) {
|
||||
name: name.trim(),
|
||||
chatPath: chatPath || null,
|
||||
modelsPath: compatMode === "cc" ? null : modelsPath || null,
|
||||
iconUrl: iconUrl?.trim() || null,
|
||||
customHeaders: customHeaders || null,
|
||||
});
|
||||
return NextResponse.json({ node }, { status: 201 });
|
||||
|
||||
5
src/lib/db/migrations/113_provider_node_icon_url.sql
Normal file
5
src/lib/db/migrations/113_provider_node_icon_url.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Add icon_url column to provider_nodes
|
||||
-- Stores an optional operator-supplied remote icon URL for OpenAI-/Anthropic-compatible
|
||||
-- provider nodes. NULL = no custom icon (falls back to the built-in @lobehub/static resolution).
|
||||
-- Plain TEXT column (no `_json` suffix) — rowToCamel passes it through as a string as-is.
|
||||
ALTER TABLE provider_nodes ADD COLUMN icon_url TEXT;
|
||||
@@ -64,6 +64,8 @@ export async function createProviderNode(data: JsonRecord) {
|
||||
baseUrl: data.baseUrl || null,
|
||||
chatPath: data.chatPath || null,
|
||||
modelsPath: data.modelsPath || null,
|
||||
// Optional operator-supplied remote icon URL (#2166) — plain TEXT, no JSON parsing needed.
|
||||
iconUrl: data.iconUrl || null,
|
||||
customHeadersJson,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -71,8 +73,8 @@ export async function createProviderNode(data: JsonRecord) {
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, chat_path, models_path, custom_headers_json, created_at, updated_at)
|
||||
VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @chatPath, @modelsPath, @customHeadersJson, @createdAt, @updatedAt)
|
||||
INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, chat_path, models_path, icon_url, custom_headers_json, created_at, updated_at)
|
||||
VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @chatPath, @modelsPath, @iconUrl, @customHeadersJson, @createdAt, @updatedAt)
|
||||
`
|
||||
).run(node);
|
||||
|
||||
@@ -119,7 +121,8 @@ export async function updateProviderNode(id: string, data: JsonRecord) {
|
||||
`
|
||||
UPDATE provider_nodes SET type = @type, name = @name, prefix = @prefix,
|
||||
api_type = @apiType, base_url = @baseUrl, chat_path = @chatPath,
|
||||
models_path = @modelsPath, custom_headers_json = @customHeadersJson, updated_at = @updatedAt
|
||||
models_path = @modelsPath, icon_url = @iconUrl,
|
||||
custom_headers_json = @customHeadersJson, updated_at = @updatedAt
|
||||
WHERE id = @id
|
||||
`
|
||||
).run({
|
||||
@@ -131,6 +134,9 @@ export async function updateProviderNode(id: string, data: JsonRecord) {
|
||||
baseUrl: merged["baseUrl"] || null,
|
||||
chatPath: merged["chatPath"] || null,
|
||||
modelsPath: merged["modelsPath"] || null,
|
||||
// #2166: iconUrl is nullable — explicit `null` (not omission) clears a previously
|
||||
// stored custom icon when the caller submits an empty value.
|
||||
iconUrl: merged["iconUrl"] || null,
|
||||
customHeadersJson: merged["customHeadersJson"] || null,
|
||||
updatedAt: merged["updatedAt"],
|
||||
});
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface ProviderCatalogMetadata {
|
||||
riskNoticeVariant?: RiskNoticeVariant;
|
||||
apiType?: string;
|
||||
baseUrl?: string;
|
||||
/** Optional operator-supplied remote icon URL (#2166) for compatible provider nodes. */
|
||||
iconUrl?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -59,6 +61,8 @@ export interface CompatibleProviderNodeLike {
|
||||
type?: string | null;
|
||||
apiType?: string | null;
|
||||
baseUrl?: string | null;
|
||||
/** Optional operator-supplied remote icon URL (#2166). */
|
||||
iconUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface CompatibleProviderLabels {
|
||||
@@ -218,6 +222,7 @@ export function resolveCompatibleProviderCatalogEntry(
|
||||
textIcon: isCcCompatible ? "CC" : isAnthropicCompatible ? "AC" : "OC",
|
||||
apiType: providerNode.apiType || undefined,
|
||||
baseUrl: providerNode.baseUrl || undefined,
|
||||
iconUrl: providerNode.iconUrl || undefined,
|
||||
type: providerNode.type,
|
||||
category: "compatible",
|
||||
displayAuthType: "compatible",
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
* ProviderIcon — Renders a provider logo using @lobehub/icons with static asset fallbacks.
|
||||
*
|
||||
* Strategy (#529):
|
||||
* 0. If `src` is set (operator-supplied remote icon URL, #2166), render it — this always
|
||||
* wins over the @lobehub/static resolution below. On load error, falls back to
|
||||
* `fallbackText`/`fallbackColor` (a colored text badge) if provided, otherwise falls
|
||||
* through to steps 1-4.
|
||||
* 1. Try @lobehub/icons direct icon components (no @lobehub/ui peer runtime)
|
||||
* 2. Fall back to /providers/{id}.png (existing static assets)
|
||||
* 3. Fall back to /providers/{id}.svg (SVG assets)
|
||||
@@ -12,6 +16,7 @@
|
||||
* Usage:
|
||||
* <ProviderIcon providerId="openai" size={24} />
|
||||
* <ProviderIcon providerId="anthropic" size={28} type="color" />
|
||||
* <ProviderIcon providerId="openai-compatible-abc" src={node.iconUrl} fallbackText="OC" />
|
||||
*/
|
||||
|
||||
import { createElement, memo, useState } from "react";
|
||||
@@ -25,6 +30,16 @@ interface ProviderIconProps {
|
||||
type?: "mono" | "color";
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
/**
|
||||
* Optional operator-supplied remote icon URL (#2166) — e.g. a custom icon set for an
|
||||
* OpenAI-/Anthropic-compatible provider node. When set, this always takes priority
|
||||
* over the @lobehub/static resolution. On load error, falls back to `fallbackText`
|
||||
* (if provided) or the normal resolution chain below.
|
||||
*/
|
||||
src?: string;
|
||||
alt?: string;
|
||||
fallbackText?: string;
|
||||
fallbackColor?: string;
|
||||
}
|
||||
|
||||
function GenericProviderIcon({ size }: { size: number }) {
|
||||
@@ -122,6 +137,10 @@ const ProviderIcon = memo(function ProviderIcon({
|
||||
type = "color",
|
||||
className,
|
||||
style,
|
||||
src,
|
||||
alt,
|
||||
fallbackText,
|
||||
fallbackColor,
|
||||
}: ProviderIconProps) {
|
||||
const normalizedId = providerId.toLowerCase();
|
||||
const lobeIcon = getLobeProviderIcon(normalizedId, type);
|
||||
@@ -129,11 +148,55 @@ const ProviderIcon = memo(function ProviderIcon({
|
||||
const hasSvg = KNOWN_SVGS.has(normalizedId);
|
||||
|
||||
const [failedAssets, setFailedAssets] = useState<Record<string, true>>({});
|
||||
const [remoteSrcFailed, setRemoteSrcFailed] = useState(false);
|
||||
const pngKey = `${normalizedId}:png`;
|
||||
const svgKey = `${normalizedId}:svg`;
|
||||
const usePng = !lobeIcon && hasPng && !failedAssets[pngKey];
|
||||
const useSvg = !lobeIcon && hasSvg && !failedAssets[svgKey] && (!hasPng || failedAssets[pngKey]);
|
||||
|
||||
const trimmedSrc = typeof src === "string" ? src.trim() : "";
|
||||
|
||||
// #2166: a custom remote icon URL always wins over the @lobehub/static resolution
|
||||
// below. It is a plain <img> (not next/image) so operators can point at any host
|
||||
// without requiring `images.remotePatterns` allow-listing for arbitrary domains.
|
||||
if (trimmedSrc && !remoteSrcFailed) {
|
||||
return (
|
||||
<span className={className} style={{ display: "inline-flex", alignItems: "center", ...style }}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- operator-supplied remote URL, not a static/known asset */}
|
||||
<img
|
||||
src={trimmedSrc}
|
||||
alt={alt || providerId}
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ objectFit: "contain", flex: "none" }}
|
||||
onError={() => setRemoteSrcFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (trimmedSrc && remoteSrcFailed && fallbackText) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: size,
|
||||
height: size,
|
||||
fontSize: Math.max(10, Math.round(size * 0.4)),
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
color: fallbackColor || "currentColor",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{fallbackText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (lobeIcon) {
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -25,6 +25,22 @@ export { validateProviderSpecificData };
|
||||
|
||||
// ──── Provider Schemas ────
|
||||
|
||||
// #2166: shared optional remote icon URL for compatible provider nodes. Empty string
|
||||
// is accepted as "no custom icon" (clears any previously stored value). Restricted to
|
||||
// http(s) — `.url()` alone also accepts syntactically-valid-but-unsafe schemes like
|
||||
// `javascript:`/`data:`, which we never want persisted as an <img src>.
|
||||
const providerNodeIconUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(2000)
|
||||
.refine((value) => value === "" || z.string().url().safeParse(value).success, {
|
||||
message: "Icon URL must be a valid URL",
|
||||
})
|
||||
.refine((value) => value === "" || /^https?:\/\//i.test(value), {
|
||||
message: "Icon URL must be a valid http:// or https:// URL",
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const createProviderSchema = z
|
||||
.object({
|
||||
provider: z.string().min(1).max(100),
|
||||
@@ -207,6 +223,11 @@ export const createProviderNodeSchema = z
|
||||
compatMode: z.enum(["cc"]).optional(),
|
||||
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
// #2166: optional operator-supplied remote icon URL for the provider node. Empty
|
||||
// string is accepted so callers can explicitly submit "no custom icon" (falls back
|
||||
// to the built-in @lobehub/static resolution). Restricted to http(s) — `.url()` alone
|
||||
// also accepts syntactically-valid-but-unsafe schemes like `javascript:`/`data:`.
|
||||
iconUrl: providerNodeIconUrlSchema,
|
||||
customHeaders: customHeadersSchema,
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -236,6 +257,9 @@ export const updateProviderNodeSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1, "Base URL is required"),
|
||||
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
// #2166: same optional remote icon URL as createProviderNodeSchema — empty string
|
||||
// clears a previously stored custom icon.
|
||||
iconUrl: providerNodeIconUrlSchema,
|
||||
customHeaders: customHeadersSchema,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user