mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12: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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
280
tests/unit/provider-node-icon-url.test.ts
Normal file
280
tests/unit/provider-node-icon-url.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
110
tests/unit/ui/ProviderIcon-icon-url.test.tsx
Normal file
110
tests/unit/ui/ProviderIcon-icon-url.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user