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:
Diego Rodrigues de Sa e Souza
2026-07-02 22:08:44 -03:00
committed by GitHub
parent 0965c54245
commit a49d34bf49
17 changed files with 611 additions and 26 deletions

View File

@@ -299,7 +299,7 @@
"tests/unit/provider-models-route.test.ts": 1752,
"tests/unit/provider-validation-specialty.test.ts": 2874,
"_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.",
"tests/unit/providers-page-utils.test.ts": 1092,
"tests/unit/providers-page-utils.test.ts": 1109,
"tests/unit/reasoning-cache.test.ts": 980,
"tests/unit/route-edge-coverage.test.ts": 1234,
"tests/unit/search-handler-extended.test.ts": 1124,

View File

@@ -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)}>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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"

View File

@@ -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}

View File

@@ -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,
});
}

View File

@@ -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,
};

View File

@@ -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 });

View 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;

View File

@@ -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"],
});

View File

@@ -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",

View File

@@ -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

View File

@@ -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,
});

View File

@@ -0,0 +1,280 @@
// #2166 — custom remote icon URL for OpenAI-/Anthropic-compatible provider nodes.
// Mirrors the structure of tests/unit/custom-headers-provider-nodes.test.ts.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-icon-url-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts");
const providerNodesIdRoute = await import("../../src/app/api/provider-nodes/[id]/route.ts");
const { OPENAI_COMPATIBLE_PREFIX } = await import("../../src/shared/constants/providers.ts");
const { createProviderNodeSchema, updateProviderNodeSchema } =
await import("../../src/shared/validation/schemas.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
function makeRequest(body: Record<string, unknown>) {
return new Request("http://localhost/api/provider-nodes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function makeUpdateRequest(id: string, body: Record<string, unknown>) {
return new Request(`http://localhost/api/provider-nodes/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("createProviderNodeSchema accepts a valid iconUrl", () => {
const result = createProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
apiType: "chat",
iconUrl: "https://example.com/logo.png",
});
assert.equal(result.success, true);
});
test("createProviderNodeSchema accepts an empty iconUrl (no custom icon)", () => {
const result = createProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
apiType: "chat",
iconUrl: "",
});
assert.equal(result.success, true);
});
test("createProviderNodeSchema accepts a missing iconUrl", () => {
const result = createProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
apiType: "chat",
});
assert.equal(result.success, true);
});
test("createProviderNodeSchema rejects a non-URL or non-http(s) iconUrl", () => {
// " " is intentionally NOT in this list — it trims to "" (no custom icon),
// matching the chatPath/modelsPath convention for optional path-like fields.
const invalidInputs = ["not-a-url", "javascript:alert(1)", "data:text/html,evil", "ftp://broken"];
for (const iconUrl of invalidInputs) {
const result = createProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
apiType: "chat",
iconUrl,
});
assert.equal(result.success, false, `Should reject: ${JSON.stringify(iconUrl)}`);
}
});
test("updateProviderNodeSchema accepts a valid iconUrl", () => {
const result = updateProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
baseUrl: "https://test.com",
iconUrl: "https://example.com/logo.png",
});
assert.equal(result.success, true);
});
test("updateProviderNodeSchema rejects a non-URL iconUrl", () => {
const result = updateProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
baseUrl: "https://test.com",
iconUrl: "not-a-url",
});
assert.equal(result.success, false);
});
test("provider nodes route creates an OpenAI-compatible node with iconUrl", async () => {
const response = await providerNodesRoute.POST(
makeRequest({
name: "Icon URL Node",
prefix: "icon-url",
apiType: "chat",
baseUrl: "https://custom.example.com/v1",
iconUrl: "https://cdn.example.com/icons/custom.png",
})
);
const body = (await response.json()) as any;
assert.equal(response.status, 201);
assert.match(body.node.id, new RegExp(`^${OPENAI_COMPATIBLE_PREFIX}chat-`));
assert.equal(body.node.iconUrl, "https://cdn.example.com/icons/custom.png");
});
test("provider nodes route creates nodes without iconUrl (null)", async () => {
const response = await providerNodesRoute.POST(
makeRequest({
name: "No Icon Node",
prefix: "no-icon",
apiType: "chat",
baseUrl: "https://noicon.example.com/v1",
})
);
const body = (await response.json()) as any;
assert.equal(response.status, 201);
assert.equal(body.node.iconUrl, null);
});
test("provider nodes route update modifies iconUrl", async () => {
const createResponse = await providerNodesRoute.POST(
makeRequest({
name: "Original Node",
prefix: "original-icon",
apiType: "chat",
baseUrl: "https://original.example.com/v1",
})
);
const created = (await createResponse.json()) as any;
const nodeId = created.node.id;
assert.equal(created.node.iconUrl, null);
const updateResponse = await providerNodesIdRoute.PUT(
makeUpdateRequest(nodeId, {
name: "Updated Node",
prefix: "updated-icon",
apiType: "chat",
baseUrl: "https://updated.example.com/v1",
iconUrl: "https://cdn.example.com/icons/updated.png",
}),
{ params: Promise.resolve({ id: nodeId }) }
);
const updated = (await updateResponse.json()) as any;
assert.equal(updateResponse.status, 200);
assert.equal(updated.node.iconUrl, "https://cdn.example.com/icons/updated.png");
});
test("provider nodes route update can clear iconUrl by passing an empty string", async () => {
const createResponse = await providerNodesRoute.POST(
makeRequest({
name: "Node With Icon",
prefix: "with-icon",
apiType: "chat",
baseUrl: "https://withicon.example.com/v1",
iconUrl: "https://cdn.example.com/icons/keep.png",
})
);
const created = (await createResponse.json()) as any;
const nodeId = created.node.id;
assert.equal(created.node.iconUrl, "https://cdn.example.com/icons/keep.png");
const updateResponse = await providerNodesIdRoute.PUT(
makeUpdateRequest(nodeId, {
name: "Node Without Icon",
prefix: "no-clear",
apiType: "chat",
baseUrl: "https://noclear.example.com/v1",
iconUrl: "",
}),
{ params: Promise.resolve({ id: nodeId }) }
);
const updated = (await updateResponse.json()) as any;
assert.equal(updateResponse.status, 200);
assert.equal(updated.node.iconUrl, null);
});
test("db: createProviderNode and getProviderNodeById round-trip iconUrl", async () => {
const node = await providersDb.createProviderNode({
id: "openai-compatible-chat-icon-url-db",
type: "openai-compatible",
name: "DB Icon URL Test",
prefix: "db-icon",
apiType: "chat",
baseUrl: "https://db.example.com/v1",
iconUrl: "https://cdn.example.com/icons/db.png",
});
assert.equal(node.iconUrl, "https://cdn.example.com/icons/db.png");
const retrieved = await providersDb.getProviderNodeById("openai-compatible-chat-icon-url-db");
assert.equal(retrieved.iconUrl, "https://cdn.example.com/icons/db.png");
});
test("db: createProviderNode without iconUrl stores null", async () => {
const node = await providersDb.createProviderNode({
id: "openai-compatible-chat-no-icon-db",
type: "openai-compatible",
name: "DB No Icon Test",
prefix: "db-no-icon",
apiType: "chat",
baseUrl: "https://db-no-icon.example.com/v1",
});
assert.equal(node.iconUrl, null);
});
test("db: updateProviderNode modifies iconUrl", async () => {
const node = await providersDb.createProviderNode({
id: "openai-compatible-chat-update-icon",
type: "openai-compatible",
name: "Update Icon Test",
prefix: "update-icon",
apiType: "chat",
baseUrl: "https://update-icon.example.com/v1",
iconUrl: "https://cdn.example.com/icons/initial.png",
});
assert.equal(node.iconUrl, "https://cdn.example.com/icons/initial.png");
const updated = await providersDb.updateProviderNode("openai-compatible-chat-update-icon", {
iconUrl: "https://cdn.example.com/icons/updated.png",
});
assert.equal(updated.iconUrl, "https://cdn.example.com/icons/updated.png");
const retrieved = await providersDb.getProviderNodeById("openai-compatible-chat-update-icon");
assert.equal(retrieved.iconUrl, "https://cdn.example.com/icons/updated.png");
});
test("db: updateProviderNode can clear iconUrl by passing null", async () => {
const node = await providersDb.createProviderNode({
id: "openai-compatible-chat-clear-icon",
type: "openai-compatible",
name: "Clear Icon Test",
prefix: "clear-icon",
apiType: "chat",
baseUrl: "https://clear-icon.example.com/v1",
iconUrl: "https://cdn.example.com/icons/clear-me.png",
});
assert.equal(node.iconUrl, "https://cdn.example.com/icons/clear-me.png");
const updated = await providersDb.updateProviderNode("openai-compatible-chat-clear-icon", {
iconUrl: null,
});
assert.equal(updated.iconUrl, null);
const retrieved = await providersDb.getProviderNodeById("openai-compatible-chat-clear-icon");
assert.equal(retrieved.iconUrl, null);
});

View File

@@ -748,6 +748,7 @@ test("compatible catalog entries keep dynamic compatible metadata", () => {
type: "openai-compatible",
apiType: "responses",
baseUrl: "https://example.test",
iconUrl: "https://cdn.example.com/icons/lab.png",
},
compatibleLabels: {
ccCompatibleName: "CC Compatible",
@@ -762,6 +763,8 @@ test("compatible catalog entries keep dynamic compatible metadata", () => {
assert.equal(compatibleProvider?.toggleAuthType, "apikey");
assert.equal(compatibleProvider?.apiType, "responses");
assert.equal(compatibleProvider?.baseUrl, "https://example.test");
// #2166: custom remote icon URL passthrough.
assert.equal(compatibleProvider?.iconUrl, "https://cdn.example.com/icons/lab.png");
});
test("model search filter matches providers by model id", async () => {
@@ -1015,7 +1018,13 @@ test("buildCompatibleProviderGroups partitions nodes by type + claude-code prefi
const groups = providerPageUtils.buildCompatibleProviderGroups(
[
{ id: "my-oai", name: "My OAI", type: "openai-compatible", apiType: "responses" },
{
id: "my-oai",
name: "My OAI",
type: "openai-compatible",
apiType: "responses",
iconUrl: "https://cdn.example.com/icons/my-oai.png",
},
{ id: "my-anthropic", name: "My Claude", type: "anthropic-compatible" },
{ id: "anthropic-compatible-cc-acme", name: "Acme CC", type: "anthropic-compatible" },
{ id: "ignored-node", name: "Ignored", type: "unsupported-provider" },
@@ -1037,6 +1046,14 @@ test("buildCompatibleProviderGroups partitions nodes by type + claude-code prefi
"missing name falls back to the openai-compatible label"
);
// #2166: custom remote icon URL passthrough.
assert.equal(
groups.openai[0].iconUrl,
"https://cdn.example.com/icons/my-oai.png",
"iconUrl is preserved for nodes that set it"
);
assert.equal(groups.openai[1].iconUrl, undefined, "iconUrl is undefined when the node has none");
assert.deepEqual(
groups.anthropic.map((p) => p.id),
["my-anthropic"],

View File

@@ -0,0 +1,110 @@
// @vitest-environment jsdom
// #2166 — ProviderIcon custom remote icon URL (`src` prop) support.
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("next/image", () => ({
default: (props: Record<string, unknown>) => {
const { onError, alt, ...rest } = props as { onError?: () => void; alt?: string } & Record<
string,
unknown
>;
// eslint-disable-next-line @next/next/no-img-element -- test double for next/image
return <img data-testid="next-image" alt={alt || ""} onError={onError} {...rest} />;
},
}));
const { default: ProviderIcon } = await import("@/shared/components/ProviderIcon");
// ── Helpers ───────────────────────────────────────────────────────────────────
// Deliberately not registered in @lobehub/icons aliases or the KNOWN_PNGS/KNOWN_SVGS
// static-asset sets, so tests exercise only the `src` override + generic-icon fallback
// paths, never the @lobehub/static resolution chain.
const UNKNOWN_PROVIDER_ID = "openai-compatible-test-node-xyz";
const containers: HTMLElement[] = [];
function renderIcon(props: Record<string, unknown>): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
containers.push(container);
const root = createRoot(container);
act(() => {
root.render(<ProviderIcon providerId={UNKNOWN_PROVIDER_ID} {...props} />);
});
return container;
}
function fireImgError(container: HTMLElement) {
const img = container.querySelector("img");
if (!img) throw new Error("expected an <img> element to fire error on");
act(() => {
img.dispatchEvent(new Event("error"));
});
}
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
});
afterEach(() => {
while (containers.length > 0) {
containers.pop()?.remove();
}
document.body.innerHTML = "";
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("ProviderIcon — custom remote icon URL (#2166)", () => {
it("renders an <img> with the given src when `src` is set", () => {
const container = renderIcon({ src: "https://example.com/logo.png", size: 32 });
const img = container.querySelector("img");
expect(img).not.toBeNull();
expect(img?.getAttribute("src")).toBe("https://example.com/logo.png");
});
it("falls back to the generic icon when `src` is unset", () => {
const container = renderIcon({});
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
it("falls back to the generic (lobehub/static) icon chain when `src` load fails and no fallbackText is given", () => {
const container = renderIcon({ src: "https://example.com/broken.png" });
expect(container.querySelector("img")).not.toBeNull();
fireImgError(container);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
it("falls back to a text badge when `src` load fails and fallbackText is given", () => {
const container = renderIcon({
src: "https://example.com/broken.png",
fallbackText: "OC",
fallbackColor: "#10A37F",
});
expect(container.querySelector("img")).not.toBeNull();
fireImgError(container);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg")).toBeNull();
expect(container.textContent).toBe("OC");
});
it("ignores a whitespace-only src and falls back to the generic icon", () => {
const container = renderIcon({ src: " " });
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
});