Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -11,6 +12,8 @@ interface ProviderNode {
apiType?: string;
chatPath?: string;
prefix?: string;
/** Optional operator-supplied remote icon URL (#2166). */
iconUrl?: string;
[key: string]: unknown;
}

Expand Down Expand Up @@ -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"}
/>
)}
Comment on lines +49 to +57

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Currently, ProviderIcon is only rendered if providerNode.iconUrl is set. This creates an inconsistent layout where cards with custom icons are indented, while cards without custom icons are not. Rendering ProviderIcon unconditionally ensures consistent alignment. By updating ProviderIcon to support a text badge fallback when no static icon is found, we can display a clean text badge ("OC", "AC", "CC") as the default fallback for all compatible nodes.

Suggested change
{providerNode.iconUrl && (
<ProviderIcon
providerId={providerId}
src={providerNode.iconUrl}
size={32}
className="shrink-0 rounded-lg"
fallbackText={isCcCompatible ? "CC" : isAnthropicCompatible ? "AC" : "OC"}
/>
)}
<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)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface EditCompatibleNodeModalNode {
baseUrl?: string;
chatPath?: string;
modelsPath?: string;
iconUrl?: string;
}

interface EditCompatibleNodeModalProps {
Expand Down Expand Up @@ -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("");
Expand All @@ -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(
!!(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface CompatibleFormState {
baseUrl: string;
chatPath: string;
modelsPath: string;
iconUrl: string;
}

const CC_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
Expand Down Expand Up @@ -75,6 +76,7 @@ function createInitialForm(mode: CompatibleMode): CompatibleFormState {
baseUrl: defaults.baseUrl,
chatPath: defaults.chatPath,
modelsPath: "",
iconUrl: "",
};
}

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
13 changes: 12 additions & 1 deletion src/app/(dashboard)/dashboard/providers/providerPageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand All @@ -168,6 +176,7 @@ export function buildCompatibleProviderGroups(
color: "#10A37F",
textIcon: "OC",
apiType: node.apiType,
iconUrl: node.iconUrl || undefined,
});
continue;
}
Expand All @@ -180,6 +189,7 @@ export function buildCompatibleProviderGroups(
name: node.name || labels.claudeCodeCompatibleName,
color: "#B45309",
textIcon: "CC",
iconUrl: node.iconUrl || undefined,
});
continue;
}
Expand All @@ -189,6 +199,7 @@ export function buildCompatibleProviderGroups(
name: node.name || labels.anthropicCompatibleName,
color: "#D97757",
textIcon: "AC",
iconUrl: node.iconUrl || undefined,
});
}

Expand Down
6 changes: 5 additions & 1 deletion src/app/api/provider-nodes/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Comment on lines +97 to +99

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In the PUT route, iconUrl is set to iconUrl?.trim() || null. If a client performs a partial update and omits iconUrl from the payload (meaning it is undefined), this expression will evaluate to null. This will cause the existing custom icon URL to be silently cleared. We should only include iconUrl in the update payload if it is explicitly provided (i.e., not undefined).

Suggested change
// #2166: explicit null (not omission) so an empty submission clears a
// previously stored custom icon.
iconUrl: iconUrl?.trim() || null,
// #2166: explicit null (not omission) so an empty submission clears a
// previously stored custom icon.
...(iconUrl !== undefined && { iconUrl: iconUrl === null ? null : iconUrl.trim() || null }),

customHeaders: customHeaders || null,
};

Expand Down
3 changes: 3 additions & 0 deletions src/app/api/provider-nodes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export async function POST(request) {
chatPath,
modelsPath,
customHeaders,
iconUrl,
} = validation.data;

// Determine type
Expand All @@ -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 });
Expand Down Expand Up @@ -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 });
Expand Down
5 changes: 5 additions & 0 deletions src/lib/db/migrations/113_provider_node_icon_url.sql
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 9 additions & 3 deletions src/lib/db/providers/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,17 @@ 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,
};

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

Expand Down Expand Up @@ -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({
Expand All @@ -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,
Comment on lines +137 to +139

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In updateProviderNode, if data does not contain iconUrl (or if it is undefined), the merged object will have merged["iconUrl"] as undefined (due to the spread operator { ...existing, ...data } overwriting keys with undefined). Then, merged["iconUrl"] || null will evaluate to null, clearing the existing icon URL. We should explicitly preserve the existing iconUrl if it is omitted (i.e., undefined) in the update payload.

Suggested change
// #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,
// #2166: iconUrl is nullable — explicit `null` (not omission) clears a previously
// stored custom icon when the caller submits an empty value.
iconUrl: data.iconUrl !== undefined ? (data.iconUrl === null ? null : data.iconUrl || null) : rowToCamel(existing).iconUrl,

customHeadersJson: merged["customHeadersJson"] || null,
updatedAt: merged["updatedAt"],
});
Expand Down
5 changes: 5 additions & 0 deletions src/lib/providers/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading