refactor(dashboard): extract useModelCompatState hook + model sections — #3501 Phase 1e (#3683)

#3501 Phase 1e: extract useModelCompatState hook (unblocks the model sections) + ModelRow/PassthroughModelsSection/PassthroughModelRow/CustomModelsSection/CompatibleModelsSection. god-component 6,838->4,921 LOC.

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-11 17:03:25 -03:00
committed by GitHub
parent 89fb1dde4d
commit 57f8137352
11 changed files with 2541 additions and 1951 deletions

View File

@@ -15,6 +15,7 @@
### ♻️ Code Quality
- **providers/[id]**: extract `useModelCompatState` hook + model sections (`ModelRow`, `PassthroughModelRow`, `PassthroughModelsSection`, `CustomModelsSection`, `CompatibleModelsSection`) from the god-component — #3501 Phase 1e. `ProviderDetailPageClient.tsx`: 6,838 → 4,922 LOC (1,916 lines). New leaf `hooks/useModelCompatState.ts` (101 LOC); compat helpers moved to `providerPageHelpers.ts`. Frozen baselines: `providerPageHelpers.ts: 822`. 12 Phase-1e smoke tests; typecheck/cycles/lint green; #3610 auto-hide fix preserved.
- **providers/[id]**: extract `ConnectionRow` (+ `CooldownTimer`/`inferErrorType`/`getStatusPresentation`), `ModelCompatPopover` (+ `recordToHeaderRows`), and `SiliconFlowEndpointModal` from the god-component into `components/`#3501 Phase 1d. `ProviderDetailPageClient.tsx`: 8,092 → 6,838 LOC (1,254 lines). Frozen baselines: `ConnectionRow.tsx: 941`. 7 new Phase-1d smoke tests; typecheck/cycles/lint green.
- **providers/[id]: extract AddApiKeyModal + EditConnectionModal (+ WebSessionCredentialGuide) from the god-component into components/** ([#3501] Phase 1c): extracted the two heaviest inline modals — `AddApiKeyModal` (~787-LOC body) and `EditConnectionModal` (~1091-LOC body) — plus shared `WebSessionCredentialGuide` (~103 LOC) into standalone files under `providers/[id]/components/modals/` and `providers/[id]/components/` respectively. Added `ERROR_TYPE_LABELS` and `formatTimeAgo` to `providerPageHelpers.ts` (leaf) so `EditConnectionModal` and `ConnectionRow` share them without cycles. Pruned 14 now-unused imports from the god-component. `ProviderDetailPageClient.tsx`: 9,981 → 8,092 LOC (1,889 lines). Frozen baselines: `AddApiKeyModal.tsx: 842`, `EditConnectionModal.tsx: 1170`. 6 new Phase-1c smoke tests; all 21 vitest modal tests pass; typecheck/cycles/lint green.
- **refactor: small db/utils cleanup** ([#3523] — thanks @androw): table-driven `compression_analytics` column migration (replaces 17 repeated `ALTER TABLE` calls), a single merged `serializeJsonField` helper in `db/providers.ts` (folded two byte-identical serializers), and removal of the dead no-op `syncProviderDataToCloud`/`getProvidersNeedingRefresh` stubs from `shared/utils/machine.ts` (no remaining callers). Pure refactor; behavior unchanged.

View File

@@ -48,7 +48,8 @@
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570,
"src/app/(dashboard)/dashboard/health/page.tsx": 1091,
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 6839,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 4948,
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 822,
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 941,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 843,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1171,

View File

@@ -0,0 +1,304 @@
// @vitest-environment jsdom
//
// Phase 1e smoke tests for Issue #3501 (strangler-fig decomposition).
// Validates:
// 1. useModelCompatState hook computes correct values from raw arrays.
// 2. ModelRow, PassthroughModelRow, ModelVisibilityToolbar render without throwing.
// 3. PassthroughModelsSection, CustomModelsSection, CompatibleModelsSection render without throwing.
// 4. No cycle: providerPageHelpers imports DO NOT import from ProviderDetailPageClient.
//
// We use shallow rendering (no Next.js server context needed) because these are
// presentational components that receive all data via props.
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
buildCompatMap,
isModelHiddenFn,
effectiveNormalizeForProtocol,
effectivePreserveForProtocol,
anyNormalizeCompatBadge,
anyNoPreserveCompatBadge,
formatProviderModelsErrorResponse,
} from "../providerPageHelpers";
// ---------------------------------------------------------------------------
// Global mocks required by the extracted components
// ---------------------------------------------------------------------------
vi.mock("next/navigation", () => ({
useParams: () => ({ id: "test-provider" }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/providers/test-provider",
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
if (values) {
return Object.entries(values).reduce(
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
key
);
}
return key;
},
}));
vi.mock("@/store/notificationStore", () => ({
useNotificationStore: () => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}),
}));
vi.mock("@/shared/components", () => ({
Badge: ({ children }: any) => <span>{children}</span>,
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
}));
// ---------------------------------------------------------------------------
// Pure-function tests for model-compat helpers (moved to providerPageHelpers)
// ---------------------------------------------------------------------------
describe("providerPageHelpers — model-compat pure functions", () => {
const customRow = {
id: "gpt-4o",
normalizeToolCallId: true,
preserveOpenAIDeveloperRole: false,
isHidden: true,
};
const customModels = [customRow];
const overrideModels: any[] = [];
it("buildCompatMap produces a Map keyed by id", () => {
const map = buildCompatMap(customModels);
expect(map.size).toBe(1);
expect(map.get("gpt-4o")).toEqual(customRow);
});
it("isModelHiddenFn reads from customMap first", () => {
const customMap = buildCompatMap(customModels);
const overrideMap = buildCompatMap(overrideModels);
expect(isModelHiddenFn("gpt-4o", customMap, overrideMap)).toBe(true);
expect(isModelHiddenFn("unknown-model", customMap, overrideMap)).toBe(false);
});
it("effectiveNormalizeForProtocol returns correct flag", () => {
const customMap = buildCompatMap(customModels);
const overrideMap = buildCompatMap(overrideModels);
expect(effectiveNormalizeForProtocol("gpt-4o", "openai", customMap, overrideMap)).toBe(true);
expect(effectiveNormalizeForProtocol("unknown", "openai", customMap, overrideMap)).toBe(false);
});
it("effectivePreserveForProtocol returns correct flag", () => {
const customMap = buildCompatMap(customModels);
const overrideMap = buildCompatMap(overrideModels);
expect(effectivePreserveForProtocol("gpt-4o", "openai", customMap, overrideMap)).toBe(false);
// Unknown model defaults to true
expect(effectivePreserveForProtocol("unknown", "openai", customMap, overrideMap)).toBe(true);
});
it("anyNormalizeCompatBadge returns true when flag is set", () => {
const customMap = buildCompatMap(customModels);
const overrideMap = buildCompatMap(overrideModels);
expect(anyNormalizeCompatBadge("gpt-4o", customMap, overrideMap)).toBe(true);
expect(anyNormalizeCompatBadge("unknown", customMap, overrideMap)).toBe(false);
});
it("anyNoPreserveCompatBadge returns true when preserve=false", () => {
const customMap = buildCompatMap(customModels);
const overrideMap = buildCompatMap(overrideModels);
expect(anyNoPreserveCompatBadge("gpt-4o", customMap, overrideMap)).toBe(true);
expect(anyNoPreserveCompatBadge("unknown", customMap, overrideMap)).toBe(false);
});
it("formatProviderModelsErrorResponse extracts error.message", async () => {
const mockRes = new Response(
JSON.stringify({ error: { message: "Model not found" } }),
{ status: 422, statusText: "Unprocessable Entity" }
);
const detail = await formatProviderModelsErrorResponse(mockRes);
expect(detail).toBe("Model not found");
});
it("formatProviderModelsErrorResponse falls back to statusText", async () => {
const mockRes = new Response("{}", { status: 500, statusText: "Internal Server Error" });
const detail = await formatProviderModelsErrorResponse(mockRes);
expect(detail).toBe("Internal Server Error");
});
});
// ---------------------------------------------------------------------------
// Component render smoke tests
// ---------------------------------------------------------------------------
describe("ModelRow — render smoke test", () => {
let container: HTMLElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("renders without throwing", async () => {
// Dynamic import to keep top-level mock resolution clean
const { default: ModelRow } = await import("../components/ModelRow");
await act(async () => {
root.render(
<ModelRow
model={{ id: "gpt-4o", name: "GPT-4o", source: "system", isHidden: false }}
fullModel="openai/gpt-4o"
provider="openai"
t={(k) => k}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
saveModelCompatFlags={vi.fn()}
getUpstreamHeadersRecord={() => ({})}
/>
);
});
expect(container.textContent).toContain("openai/gpt-4o");
});
});
describe("PassthroughModelRow — render smoke test", () => {
let container: HTMLElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => { root.unmount(); });
container.remove();
});
it("renders without throwing", async () => {
const { default: PassthroughModelRow } = await import("../components/PassthroughModelRow");
await act(async () => {
root.render(
<PassthroughModelRow
modelId="some-model"
fullModel="openrouter/some-model"
t={(k) => k}
onCopy={vi.fn()}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
saveModelCompatFlags={vi.fn()}
getUpstreamHeadersRecord={() => ({})}
/>
);
});
expect(container.textContent).toContain("openrouter/some-model");
});
});
describe("ModelVisibilityToolbar — render smoke test", () => {
let container: HTMLElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => { root.unmount(); });
container.remove();
});
it("renders without throwing", async () => {
const { ModelVisibilityToolbar } = await import("../components/ModelRow");
await act(async () => {
root.render(
<ModelVisibilityToolbar
t={(k) => k}
filterValue=""
onFilterChange={vi.fn()}
activeCount={5}
totalCount={10}
onSelectAll={vi.fn()}
onDeselectAll={vi.fn()}
/>
);
});
// toolbar renders filter input
expect(container.querySelector("input")).not.toBeNull();
});
});
describe("useModelCompatState — hook unit test via component wrapper", () => {
let container: HTMLElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => { root.unmount(); });
container.remove();
});
it("exposes isModelHidden, effectiveModelNormalize, anyNormalizeCompatBadge correctly", async () => {
const { useModelCompatState } = await import("../hooks/useModelCompatState");
const customModels = [
{ id: "gpt-4o", normalizeToolCallId: true, preserveOpenAIDeveloperRole: false, isHidden: true },
];
const modelCompatOverrides: any[] = [];
// Capture results via a data-testid attribute on a span to avoid hook-mutation rules
function TestWrapper() {
const compat = useModelCompatState(customModels, modelCompatOverrides);
const results = [
compat.isModelHidden("gpt-4o"),
compat.isModelHidden("unknown"),
compat.effectiveModelNormalize("gpt-4o"),
compat.effectiveModelPreserveDeveloper("gpt-4o"),
compat.anyNormalizeCompatBadge("gpt-4o"),
compat.anyNoPreserveCompatBadge("gpt-4o"),
].map(String).join(",");
return <span data-testid="results">{results}</span>;
}
await act(async () => {
root.render(<TestWrapper />);
});
const span = container.querySelector("[data-testid='results']");
expect(span).not.toBeNull();
const [hidden, notHidden, normalize, preserve, anyNorm, anyNoPreserve] =
(span!.textContent ?? "").split(",");
expect(hidden).toBe("true");
expect(notHidden).toBe("false");
expect(normalize).toBe("true");
expect(preserve).toBe("false");
expect(anyNorm).toBe("true");
expect(anyNoPreserve).toBe("true");
});
});

View File

@@ -0,0 +1,463 @@
"use client";
/**
* CompatibleModelsSection — Issue #3501 Phase 1e
*
* Extracted from ProviderDetailPageClient.tsx. Renders the models panel for
* compatible providers (OpenAI-compat, Anthropic-compat, CC-compat, OpenRouter).
*
* Never imports from ProviderDetailPageClient.
*/
import React, { useState, useCallback, useMemo } from "react";
import { Button } from "@/shared/components";
import {
matchesModelCatalogQuery,
normalizeModelCatalogSource,
} from "@/shared/utils/modelCatalogSearch";
import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases";
import { useNotificationStore } from "@/store/notificationStore";
import {
buildCompatMap,
providerText,
type CompatModelRow,
} from "../providerPageHelpers";
import { ModelVisibilityToolbar } from "./ModelRow";
import PassthroughModelRow, { type PassthroughModelRowProps } from "./PassthroughModelRow";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
export type CompatibleModelsSaveFlags = {
normalizeToolCallId?: boolean;
preserveDeveloperRole?: boolean;
preserveOpenAIDeveloperRole?: boolean;
isHidden?: boolean;
};
export interface CompatibleModelsSectionProps {
providerStorageAlias: string;
providerDisplayAlias: string;
modelAliases: Record<string, string>;
availableModels?: CompatModelRow[];
customModels?: CompatModelRow[];
fallbackModels?: CompatModelRow[];
allowImport: boolean;
description: string;
inputLabel: string;
inputPlaceholder: string;
copied?: string;
onCopy: (text: string, key: string) => void;
onSetAlias: (modelId: string, alias: string, providerStorageAlias?: string) => Promise<void>;
onDeleteAlias: (alias: string) => void;
connections: { id?: string; isActive?: boolean }[];
isAnthropic?: boolean;
onImportWithProgress: (connectionId: string) => Promise<void>;
t: (key: string, values?: Record<string, unknown>) => string;
effectiveModelNormalize: (alias: string) => boolean;
effectiveModelPreserveDeveloper: (alias: string) => boolean;
getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record<string, string>;
saveModelCompatFlags: (
modelId: string,
flags: CompatibleModelsSaveFlags
) => Promise<void>;
compatSavingModelId?: string;
onModelsChanged?: () => void;
isModelHidden: (modelId: string) => boolean;
onToggleHidden: (modelId: string, hidden: boolean) => Promise<void>;
onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise<void>;
bulkTogglePending?: boolean;
togglingModelId?: string | null;
onTestModel?: (modelId: string, fullModel: string) => Promise<void>;
modelTestStatus?: Record<string, "ok" | "error" | null>;
testingModelId?: string | null;
onTestAll?: (targets: Array<{ modelId: string; fullModel: string }>) => Promise<void>;
testingAll?: boolean;
testProgress?: { done: number; total: number } | null;
autoHideFailed?: boolean;
onAutoHideFailedChange?: (v: boolean) => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function CompatibleModelsSection({
providerStorageAlias,
providerDisplayAlias,
modelAliases,
availableModels = [],
customModels = [],
fallbackModels = [],
description,
inputLabel,
inputPlaceholder,
copied,
onCopy,
onSetAlias,
onDeleteAlias,
connections,
isAnthropic,
onImportWithProgress,
t,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
getUpstreamHeadersRecord,
saveModelCompatFlags,
compatSavingModelId,
onModelsChanged,
allowImport,
isModelHidden,
onToggleHidden,
onBulkToggleHidden,
bulkTogglePending,
togglingModelId,
onTestModel,
modelTestStatus,
testingModelId,
onTestAll,
testingAll,
testProgress,
autoHideFailed,
onAutoHideFailedChange,
}: CompatibleModelsSectionProps) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
const [importing, setImporting] = useState(false);
const [modelFilter, setModelFilter] = useState("");
const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all");
const notify = useNotificationStore();
const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]);
const providerAliases = useMemo(
() =>
Object.entries(modelAliases).filter(([, model]: [string, any]) =>
(model as string).startsWith(`${providerStorageAlias}/`)
),
[modelAliases, providerStorageAlias]
);
const allModels = useMemo(() => {
const prefix = `${providerStorageAlias}/`;
const aliasByModelId = new Map<string, string>();
const rows: Array<{
modelId: string;
alias: string | null;
displayName: string;
source: string;
isFree: boolean;
isHidden: boolean;
}> = [];
const seenModelIds = new Set<string>();
for (const [alias, fullModel] of providerAliases) {
const fmStr = fullModel as string;
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
aliasByModelId.set(modelId, alias as string);
}
const addModel = (model: CompatModelRow, source: string) => {
if (!model?.id || seenModelIds.has(model.id)) return;
rows.push({
modelId: model.id,
alias: aliasByModelId.get(model.id) || null,
displayName: model.name || model.id,
source,
isFree:
Boolean((model as any).free) ||
model.id.endsWith(":free") ||
/\bgr[aá]tis\b|\bfree\b/i.test(model.name || ""),
isHidden: isModelHidden(model.id),
});
seenModelIds.add(model.id);
};
for (const model of availableModels) {
addModel(model, "imported");
}
for (const model of customModels) {
addModel(
model,
normalizeModelCatalogSource(model.source) === "imported" ? "imported" : "custom"
);
}
for (const model of fallbackModels) {
addModel(model, "fallback");
}
for (const [alias, fullModel] of providerAliases) {
const fmStr = fullModel as string;
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
if (!modelId || seenModelIds.has(modelId)) continue;
const customModel = customModelMap.get(modelId);
rows.push({
modelId,
alias: alias as string,
displayName: alias as string,
source: customModel ? customModel.source || "custom" : "alias",
isFree:
modelId.endsWith(":free") ||
Boolean((customModel as any)?.free) ||
/\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || ""),
isHidden: isModelHidden(modelId),
});
seenModelIds.add(modelId);
}
return rows;
}, [
availableModels,
customModelMap,
customModels,
fallbackModels,
isModelHidden,
providerAliases,
providerStorageAlias,
]);
const filteredModels = allModels.filter((model) => {
const matchesQuery = matchesModelCatalogQuery(modelFilter, {
modelId: model.modelId,
modelName: model.displayName,
alias: model.alias,
source: model.source,
});
const matchesVisibility =
visibilityFilter === "all"
? true
: visibilityFilter === "visible"
? !model.isHidden
: model.isHidden;
return matchesQuery && matchesVisibility;
});
const activeCount = allModels.filter((model) => !model.isHidden).length;
const hiddenFilteredCount = filteredModels.filter((model) => model.isHidden).length;
const visibleFilteredCount = filteredModels.length - hiddenFilteredCount;
const resolveAlias = useCallback(
(modelId: string, workingAliases: Record<string, string>) =>
resolveManagedModelAlias({
modelId,
fullModel: `${providerStorageAlias}/${modelId}`,
providerDisplayAlias,
existingAliases: workingAliases,
}),
[providerDisplayAlias, providerStorageAlias]
);
const handleAdd = async () => {
if (!newModel.trim() || adding) return;
const modelId = newModel.trim();
const resolvedAlias = resolveAlias(modelId, modelAliases);
if (!resolvedAlias) {
notify.error(t("allSuggestedAliasesExist"));
return;
}
setAdding(true);
try {
// Save to customModels DB FIRST - only create alias if this succeeds
const customModelRes = await fetch("/api/provider-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: providerStorageAlias,
modelId,
modelName: modelId,
source: "manual",
}),
});
if (!customModelRes.ok) {
let errorData: { error?: { message?: string } } = {};
try {
errorData = await customModelRes.json();
} catch (jsonError) {
console.error("Failed to parse error response from custom model API:", jsonError);
}
throw new Error(errorData.error?.message || t("failedSaveCustomModel"));
}
// Only create alias after customModel is saved successfully
await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
setNewModel("");
notify.success(t("modelAddedSuccess", { modelId }));
onModelsChanged?.();
} catch (error) {
console.error("Error adding model:", error);
notify.error(error instanceof Error ? error.message : t("failedAddModelTryAgain"));
} finally {
setAdding(false);
}
};
const handleImport = async () => {
if (!allowImport || importing) return;
const activeConnection = connections.find((conn) => conn.isActive !== false);
if (!activeConnection?.id) return;
setImporting(true);
try {
await onImportWithProgress(activeConnection.id);
} catch (error) {
console.error("Error importing models:", error);
notify.error(t("failedImportModelsTryAgain"));
} finally {
setImporting(false);
}
};
const canImport = connections.some((conn) => conn.isActive !== false);
// Handle delete: remove from both alias and customModels DB
const handleDeleteModel = async (modelId: string, alias?: string | null) => {
try {
// Remove from customModels DB
const res = await fetch(
`/api/provider-models?provider=${encodeURIComponent(providerStorageAlias)}&model=${encodeURIComponent(modelId)}`,
{ method: "DELETE" }
);
if (!res.ok) {
throw new Error(t("failedRemoveModelFromDatabase"));
}
// Also delete the alias
if (alias) {
await onDeleteAlias(alias);
}
notify.success(t("modelRemovedSuccess"));
onModelsChanged?.();
} catch (error) {
console.error("Error deleting model:", error);
notify.error(error instanceof Error ? error.message : t("failedDeleteModelTryAgain"));
}
};
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">{description}</p>
<div className="flex items-end gap-2 flex-wrap">
<div className="flex-1 min-w-[240px]">
<label
htmlFor="new-compatible-model-input"
className="text-xs text-text-muted mb-1 block"
>
{inputLabel}
</label>
<input
id="new-compatible-model-input"
type="text"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder={inputPlaceholder}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</div>
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModel.trim() || adding}>
{adding ? t("adding") : t("add")}
</Button>
{allowImport && (
<Button
size="sm"
variant="secondary"
icon="download"
onClick={handleImport}
disabled={!canImport || importing}
>
{importing ? t("importingModels") : t("importFromModels")}
</Button>
)}
</div>
{allowImport && !canImport && (
<p className="text-xs text-text-muted">{t("addConnectionToImport")}</p>
)}
{allModels.length > 0 && (
<div className="flex flex-col gap-3">
<ModelVisibilityToolbar
t={t}
filterValue={modelFilter}
onFilterChange={setModelFilter}
activeCount={activeCount}
totalCount={allModels.length}
onSelectAll={() =>
onBulkToggleHidden(
filteredModels.map((model) => model.modelId),
false
)
}
onDeselectAll={() =>
onBulkToggleHidden(
filteredModels.map((model) => model.modelId),
true
)
}
selectAllDisabled={hiddenFilteredCount === 0 || bulkTogglePending}
deselectAllDisabled={visibleFilteredCount === 0 || bulkTogglePending}
visibilityFilter={visibilityFilter}
onVisibilityFilterChange={setVisibilityFilter}
onTestAll={() => {
const targets = filteredModels
.filter((m) => !m.isHidden)
.map((m) => ({
modelId: m.modelId,
fullModel: `${providerDisplayAlias}/${m.modelId}`,
}));
return onTestAll?.(targets);
}}
testingAll={testingAll}
testProgress={testProgress}
autoHideFailed={autoHideFailed}
onAutoHideFailedChange={onAutoHideFailedChange}
/>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{filteredModels.map(({ modelId, alias, isHidden, source, isFree }) => {
const fullModel = `${providerDisplayAlias}/${modelId}`;
return (
<PassthroughModelRow
key={`${providerStorageAlias}:${modelId}`}
modelId={modelId}
fullModel={fullModel}
source={source}
isFree={isFree}
isHidden={isHidden}
copied={copied}
onCopy={onCopy}
onDeleteAlias={
source === "custom" || source === "manual"
? () => handleDeleteModel(modelId, alias)
: source === "alias" && alias
? () => onDeleteAlias(alias)
: undefined
}
t={t}
showDeveloperToggle={!isAnthropic}
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)}
saveModelCompatFlags={saveModelCompatFlags}
compatDisabled={compatSavingModelId === modelId}
onToggleHidden={onToggleHidden}
togglingHidden={togglingModelId === modelId}
onTestModel={onTestModel}
testStatus={modelTestStatus?.[modelId] || null}
testingModel={testingModelId === modelId}
/>
);
})}
</div>
{filteredModels.length === 0 && modelFilter && (
<p className="py-2 text-sm text-text-muted">
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
filter: modelFilter,
})}
</p>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,562 @@
"use client";
/**
* CustomModelsSection — Issue #3501 Phase 1e
*
* Extracted from ProviderDetailPageClient.tsx. Renders the "custom models"
* panel for ALL providers. This section is self-contained: it fetches its
* own model state from the API and manages local loading/saving state.
*
* Never imports from ProviderDetailPageClient.
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import {
buildCompatMap,
anyNormalizeCompatBadge,
anyNoPreserveCompatBadge,
anyUpstreamHeadersBadge,
effectiveNormalizeForProtocol,
effectivePreserveForProtocol,
effectiveUpstreamHeadersForProtocol,
formatProviderModelsErrorResponse,
type CompatModelRow,
type CompatByProtocolMap,
} from "../providerPageHelpers";
import ModelCompatPopover from "./ModelCompatPopover";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
export interface CustomModelsSectionProps {
providerId: string;
providerAlias: string;
copied?: string;
onCopy: (text: string, key: string) => void;
onModelsChanged?: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function CustomModelsSection({
providerId,
providerAlias,
copied,
onCopy,
onModelsChanged,
}: CustomModelsSectionProps) {
const t = useTranslations("providers");
const notify = useNotificationStore();
const [customModels, setCustomModels] = useState<CompatModelRow[]>([]);
const [modelCompatOverrides, setModelCompatOverrides] = useState<
Array<CompatModelRow & { id: string }>
>([]);
const [newModelId, setNewModelId] = useState("");
const [newModelName, setNewModelName] = useState("");
const [newApiFormat, setNewApiFormat] = useState("chat-completions");
const [newEndpoints, setNewEndpoints] = useState(["chat"]);
const [adding, setAdding] = useState(false);
const [loading, setLoading] = useState(true);
const [editingModelId, setEditingModelId] = useState<string | null>(null);
const [editingApiFormat, setEditingApiFormat] = useState("chat-completions");
const [editingEndpoints, setEditingEndpoints] = useState<string[]>(["chat"]);
const [savingModelId, setSavingModelId] = useState<string | null>(null);
const [togglingModelId, setTogglingModelId] = useState<string | null>(null);
const customMap = useMemo(() => buildCompatMap(customModels), [customModels]);
const overrideMap = useMemo(() => buildCompatMap(modelCompatOverrides), [modelCompatOverrides]);
const fetchCustomModels = useCallback(async () => {
try {
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`);
if (res.ok) {
const data = await res.json();
setCustomModels(data.models || []);
setModelCompatOverrides(data.modelCompatOverrides || []);
}
} catch (e) {
console.error("Failed to fetch custom models:", e);
} finally {
setLoading(false);
}
}, [providerId]);
useEffect(() => {
fetchCustomModels();
}, [fetchCustomModels]);
const handleAdd = async () => {
if (!newModelId.trim() || adding) return;
setAdding(true);
try {
const res = await fetch("/api/provider-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: providerId,
modelId: newModelId.trim(),
modelName: newModelName.trim() || undefined,
apiFormat: newApiFormat,
supportedEndpoints: newEndpoints,
}),
});
if (res.ok) {
setNewModelId("");
setNewModelName("");
setNewApiFormat("chat-completions");
setNewEndpoints(["chat"]);
await fetchCustomModels();
onModelsChanged?.();
}
} catch (e) {
console.error("Failed to add custom model:", e);
} finally {
setAdding(false);
}
};
const handleRemove = async (modelId: string) => {
try {
await fetch(
`/api/provider-models?provider=${encodeURIComponent(providerId)}&model=${encodeURIComponent(modelId)}`,
{
method: "DELETE",
}
);
await fetchCustomModels();
onModelsChanged?.();
} catch (e) {
console.error("Failed to remove custom model:", e);
}
};
const handleToggleHidden = async (modelId: string, hidden: boolean) => {
setTogglingModelId(modelId);
try {
const res = await fetch(
`/api/provider-models?provider=${encodeURIComponent(providerId)}&modelId=${encodeURIComponent(modelId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isHidden: hidden }),
}
);
if (res.ok) {
await fetchCustomModels();
onModelsChanged?.();
}
} catch (e) {
console.error("Failed to toggle model visibility:", e);
} finally {
setTogglingModelId(null);
}
};
const beginEdit = (model: CompatModelRow) => {
setEditingModelId(model.id ?? null);
setEditingApiFormat(model.apiFormat || "chat-completions");
setEditingEndpoints(
Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length
? model.supportedEndpoints
: ["chat"]
);
};
const cancelEdit = () => {
setEditingModelId(null);
setEditingApiFormat("chat-completions");
setEditingEndpoints(["chat"]);
setSavingModelId(null);
};
const saveCustomCompat = async (
modelId: string,
patch: { compatByProtocol?: CompatByProtocolMap }
) => {
setSavingModelId(modelId);
try {
const res = await fetch("/api/provider-models", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId, modelId, ...patch }),
});
if (!res.ok) {
const detail = await formatProviderModelsErrorResponse(res);
notify.error(
detail ? `${t("failedSaveCustomModel")}${detail}` : t("failedSaveCustomModel")
);
return;
}
} catch {
notify.error(t("failedSaveCustomModel"));
return;
} finally {
setSavingModelId(null);
}
try {
await fetchCustomModels();
onModelsChanged?.();
} catch {
/* refresh failure is non-critical — data was already saved */
}
};
const saveEdit = async (modelId: string) => {
if (!editingModelId || editingModelId !== modelId) return;
if (!editingEndpoints.length) {
notify.error("Select at least one supported endpoint");
return;
}
setSavingModelId(modelId);
try {
const model = customModels.find((m) => m.id === modelId);
const res = await fetch("/api/provider-models", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: providerId,
modelId,
modelName: model?.name || modelId,
source: model?.source || "manual",
apiFormat: editingApiFormat,
supportedEndpoints: editingEndpoints,
}),
});
if (!res.ok) {
const detail = await formatProviderModelsErrorResponse(res);
throw new Error(detail || "Failed to save model endpoint settings");
}
await fetchCustomModels();
onModelsChanged?.();
notify.success("Saved model endpoint settings");
cancelEdit();
} catch (e) {
console.error("Failed to save custom model:", e);
notify.error(
e instanceof Error && e.message ? e.message : "Failed to save model endpoint settings"
);
} finally {
setSavingModelId(null);
}
};
return (
<div className="mt-6 pt-6 border-t border-border">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-base text-primary">tune</span>
{t("customModels")}
</h3>
<p className="text-xs text-text-muted mb-3">{t("customModelsHint")}</p>
{/* Add form */}
<div className="flex flex-col gap-3 mb-3">
<div className="flex items-end gap-2">
<div className="flex-1">
<label htmlFor="custom-model-id" className="text-xs text-text-muted mb-1 block">
{t("modelId")}
</label>
<input
id="custom-model-id"
type="text"
value={newModelId}
onChange={(e) => setNewModelId(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder={t("customModelPlaceholder")}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</div>
<div className="w-40">
<label htmlFor="custom-model-name" className="text-xs text-text-muted mb-1 block">
{t("displayName")}
</label>
<input
id="custom-model-name"
type="text"
value={newModelName}
onChange={(e) => setNewModelName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder={t("optional")}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</div>
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModelId.trim() || adding}>
{adding ? t("adding") : t("add")}
</Button>
</div>
{/* API Format + Supported Endpoints */}
<div className="flex items-end gap-4 flex-wrap">
<div className="w-48">
<label htmlFor="custom-api-format" className="text-xs text-text-muted mb-1 block">
API Format
</label>
<select
id="custom-api-format"
value={newApiFormat}
onChange={(e) => setNewApiFormat(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="chat-completions">{t("chatCompletions")}</option>
<option value="responses">{t("responsesApi")}</option>
<option value="embeddings">{t("embeddings")}</option>
<option value="rerank">Rerank</option>
<option value="audio-transcriptions">{t("audioTranscriptions")}</option>
<option value="audio-speech">{t("audioSpeech")}</option>
<option value="images-generations">{t("imagesGenerations")}</option>
</select>
</div>
<div className="flex-1">
<span className="text-xs text-text-muted mb-1 block">
{t("supportedEndpointsLabel")}
</span>
<div className="flex items-center gap-3">
{["chat", "embeddings", "rerank", "images", "audio"].map((ep) => (
<label
key={ep}
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer"
>
<input
type="checkbox"
checked={newEndpoints.includes(ep)}
onChange={(e) => {
if (e.target.checked) {
setNewEndpoints((prev) => [...prev, ep]);
} else {
setNewEndpoints((prev) => prev.filter((x) => x !== ep));
}
}}
className="rounded border-border"
/>
{ep === "chat"
? `💬 ${t("supportedEndpointChat")}`
: ep === "embeddings"
? `📐 ${t("supportedEndpointEmbeddings")}`
: ep === "rerank"
? "Rerank"
: ep === "images"
? `🖼️ ${t("supportedEndpointImages")}`
: `🔊 ${t("supportedEndpointAudio")}`}
</label>
))}
</div>
</div>
</div>
</div>
{/* List */}
{loading ? (
<p className="text-xs text-text-muted">{t("loading")}</p>
) : customModels.length > 0 ? (
<div className="flex flex-col gap-2">
{customModels.map((model) => {
const fullModel = `${providerAlias}/${model.id}`;
const copyKey = `custom-${model.id}`;
return (
<div
key={model.id}
className="flex items-center gap-3 rounded-lg border border-border p-3 hover:bg-sidebar/50"
>
{editingModelId !== model.id && (
<span className="material-symbols-outlined text-base text-primary shrink-0">
tune
</span>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{model.name || model.id}</p>
<div className="flex items-center gap-1 mt-1 flex-wrap">
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">
{fullModel}
</code>
<button
onClick={() => onCopy(fullModel, copyKey)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title={t("copyModel")}
>
<span className="material-symbols-outlined text-sm">
{copied === copyKey ? "check" : "content_copy"}
</span>
</button>
{model.apiFormat === "responses" && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 font-medium">
{t("responses")}
</span>
)}
{model.supportedEndpoints?.includes("embeddings") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-500/15 text-purple-400 font-medium">
{`📐 ${t("supportedEndpointEmbeddings")}`}
</span>
)}
{model.supportedEndpoints?.includes("images") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/15 text-amber-400 font-medium">
{`🖼️ ${t("imagesShortLabel")}`}
</span>
)}
{model.supportedEndpoints?.includes("audio") && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">
{`🔊 ${t("audioShortLabel")}`}
</span>
)}
{anyNormalizeCompatBadge(model.id!, customMap, overrideMap) && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-500/15 text-slate-400 font-medium"
title={t("normalizeToolCallIdLabel")}
>
ID×9
</span>
)}
{anyNoPreserveCompatBadge(model.id!, customMap, overrideMap) && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-cyan-500/15 text-cyan-400 font-medium"
title={t("compatDoNotPreserveDeveloper")}
>
{t("compatBadgeNoPreserve")}
</span>
)}
{anyUpstreamHeadersBadge(model.id!, customMap, overrideMap) && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-violet-500/15 text-violet-400 font-medium"
title={t("compatUpstreamHeadersLabel")}
>
{t("compatBadgeUpstreamHeaders")}
</span>
)}
</div>
{editingModelId === model.id && (
<div className="mt-3 min-w-0 max-w-full rounded-lg border border-border bg-muted p-3 dark:bg-zinc-900">
<div className="flex min-w-0 flex-wrap items-end gap-x-3 gap-y-2">
<div className="w-[11rem] shrink-0 min-w-0">
<label className="text-xs text-text-muted mb-1 block">
{t("apiFormatLabel")}
</label>
<select
value={editingApiFormat}
onChange={(e) => setEditingApiFormat(e.target.value)}
className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background text-text-main focus:outline-none focus:border-primary"
>
<option value="chat-completions">{t("chatCompletions")}</option>
<option value="responses">{t("responsesApi")}</option>
<option value="embeddings">{t("embeddings")}</option>
<option value="rerank">Rerank</option>
<option value="audio-transcriptions">
{t("audioTranscriptions")}
</option>
<option value="audio-speech">{t("audioSpeech")}</option>
<option value="images-generations">{t("imagesGenerations")}</option>
</select>
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1 overflow-x-auto overflow-y-visible [scrollbar-width:thin]">
<span className="text-xs text-text-muted shrink-0">
{t("supportedEndpointsLabel")}
</span>
<div className="flex flex-wrap items-center gap-x-2 sm:gap-x-3 gap-y-1 min-w-0">
{["chat", "embeddings", "rerank", "images", "audio"].map((ep) => (
<label
key={ep}
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap"
>
<input
type="checkbox"
checked={editingEndpoints.includes(ep)}
onChange={(e) => {
if (e.target.checked) {
setEditingEndpoints((prev) =>
prev.includes(ep) ? prev : [...prev, ep]
);
} else {
setEditingEndpoints((prev) => prev.filter((x) => x !== ep));
}
}}
className="rounded border-border"
/>
{ep === "chat"
? `💬 ${t("supportedEndpointChat")}`
: ep === "embeddings"
? `📐 ${t("supportedEndpointEmbeddings")}`
: ep === "rerank"
? "Rerank"
: ep === "images"
? `🖼️ ${t("supportedEndpointImages")}`
: `🔊 ${t("supportedEndpointAudio")}`}
</label>
))}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 pb-0.5">
<Button
size="sm"
onClick={() => saveEdit(model.id!)}
disabled={savingModelId === model.id}
>
{savingModelId === model.id ? t("saving") : t("save")}
</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>
{t("cancel")}
</Button>
</div>
</div>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => beginEdit(model)}
className="rounded p-1 text-text-muted hover:bg-sidebar hover:text-primary"
title={t("edit")}
>
<span className="material-symbols-outlined text-sm">edit</span>
</button>
<ModelCompatPopover
t={t}
effectiveModelNormalize={(p) =>
effectiveNormalizeForProtocol(model.id!, p, customMap, overrideMap)
}
effectiveModelPreserveDeveloper={(p) =>
effectivePreserveForProtocol(model.id!, p, customMap, overrideMap)
}
getUpstreamHeadersRecord={(p) =>
effectiveUpstreamHeadersForProtocol(model.id!, p, customMap, overrideMap)
}
onCompatPatch={(protocol, payload) =>
saveCustomCompat(model.id!, {
compatByProtocol: { [protocol]: payload },
})
}
showDeveloperToggle
disabled={savingModelId === model.id}
/>
<button
onClick={() => handleToggleHidden(model.id!, !model.isHidden)}
disabled={togglingModelId === model.id}
className="rounded p-1 text-text-muted hover:bg-sidebar hover:text-primary disabled:opacity-50"
title={model.isHidden ? t("unhideModel") : t("hideModel")}
>
<span className="material-symbols-outlined text-sm">
{model.isHidden ? "visibility_off" : "visibility"}
</span>
</button>
<button
onClick={() => handleRemove(model.id!)}
className="rounded p-1 text-red-500 hover:bg-red-50"
title={t("removeCustomModel")}
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
</div>
</div>
);
})}
</div>
) : (
<p className="text-xs text-text-muted">{t("noCustomModels")}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,330 @@
"use client";
/**
* ModelRow — Issue #3501 Phase 1e
*
* Extracted from ProviderDetailPageClient.tsx. Contains:
* - ModelSourceBadge (tiny utility)
* - ModelVisibilityToolbar (filter/test toolbar)
* - ModelRow (catalog model row with compat controls)
*
* Leaf component: imports from shared, leaf helpers, and sibling components.
* Never imports from ProviderDetailPageClient.
*/
import React from "react";
import {
getModelCatalogSourceLabel,
normalizeModelCatalogSource,
} from "@/shared/utils/modelCatalogSearch";
import { providerText } from "../providerPageHelpers";
import ModelCompatPopover from "./ModelCompatPopover";
// ---------------------------------------------------------------------------
// Shared prop types
// ---------------------------------------------------------------------------
/** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */
export type ModelCompatSavePatch = {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
upstreamHeaders?: Record<string, string>;
compatByProtocol?: Record<
string,
{
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
upstreamHeaders?: Record<string, string>;
}
>;
isHidden?: boolean;
};
// ---------------------------------------------------------------------------
// ModelSourceBadge
// ---------------------------------------------------------------------------
function getModelSourceBadgeClass(source?: string): string {
switch (normalizeModelCatalogSource(source)) {
case "imported":
return "border-sky-500/30 bg-sky-500/10 text-sky-300";
case "custom":
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-300";
case "fallback":
return "border-amber-500/30 bg-amber-500/10 text-amber-300";
case "alias":
return "border-violet-500/30 bg-violet-500/10 text-violet-300";
case "system":
default:
return "border-border bg-sidebar/70 text-text-muted";
}
}
export function ModelSourceBadge({ source }: { source?: string }) {
return (
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${getModelSourceBadgeClass(
source
)}`}
>
{getModelCatalogSourceLabel(source)}
</span>
);
}
// ---------------------------------------------------------------------------
// ModelVisibilityToolbar
// ---------------------------------------------------------------------------
export interface ModelVisibilityToolbarProps {
t: ((key: string, values?: Record<string, unknown>) => string) & {
has?: (key: string) => boolean;
};
filterValue: string;
onFilterChange: (value: string) => void;
activeCount: number;
totalCount: number;
onSelectAll: () => void;
onDeselectAll: () => void;
selectAllDisabled?: boolean;
deselectAllDisabled?: boolean;
onTestAll?: () => void;
testingAll?: boolean;
testProgress?: { done: number; total: number } | null;
visibilityFilter?: "all" | "visible" | "hidden";
onVisibilityFilterChange?: (filter: "all" | "visible" | "hidden") => void;
autoHideFailed?: boolean;
onAutoHideFailedChange?: (v: boolean) => void;
}
export function ModelVisibilityToolbar({
t,
filterValue,
onFilterChange,
activeCount: _activeCount,
totalCount: _totalCount,
onSelectAll,
onDeselectAll,
selectAllDisabled,
deselectAllDisabled,
onTestAll,
testingAll,
testProgress,
visibilityFilter,
onVisibilityFilterChange,
autoHideFailed,
onAutoHideFailedChange,
}: ModelVisibilityToolbarProps) {
return (
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-[220px] flex-1">
<span className="material-symbols-outlined pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-[15px] text-text-muted">
search
</span>
<input
type="text"
value={filterValue}
onChange={(e) => onFilterChange(e.target.value)}
placeholder={providerText(t, "filterModels", "Filter models…")}
className="w-full rounded-lg border border-border bg-sidebar/50 py-1.5 pl-7 pr-3 text-xs text-text-main placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{visibilityFilter !== undefined && onVisibilityFilterChange && (
<div className="flex items-center gap-1 rounded-lg border border-border bg-sidebar/50 p-0.5">
{(["all", "visible", "hidden"] as const).map((f) => (
<button
key={f}
onClick={() => onVisibilityFilterChange(f)}
className={`rounded px-2 py-1 text-xs ${
visibilityFilter === f
? "bg-primary text-primary-foreground"
: "text-text-muted hover:text-text-main"
}`}
>
{f === "all"
? providerText(t, "showAllModels", "All")
: f === "visible"
? providerText(t, "showVisibleOnly", "Visible")
: providerText(t, "showHiddenOnly", "Hidden")}
</button>
))}
</div>
)}
{onAutoHideFailedChange && (
<label className="flex items-center gap-1.5 text-xs text-text-muted">
<input
type="checkbox"
checked={autoHideFailed ?? false}
onChange={(e) => onAutoHideFailedChange(e.target.checked)}
className="rounded border-border bg-sidebar"
/>
{providerText(t, "hideFailedAuto", "Auto-hide failed")}
</label>
)}
{onTestAll && (
<button
onClick={onTestAll}
disabled={testingAll}
className="flex items-center gap-1.5 rounded-lg border border-border bg-transparent px-2.5 py-1 text-[12px] text-text-main disabled:cursor-not-allowed disabled:opacity-50"
title={providerText(t, "testAllModels", "Test all")}
>
<span className="material-symbols-outlined text-[16px]">
{testingAll ? "progress_activity" : "science"}
</span>
<span>
{testingAll && testProgress
? providerText(t, "testingAllModels", "Testing {done}/{total}", testProgress)
: providerText(t, "testAllModels", "Test all")}
</span>
</button>
)}
<button
onClick={onSelectAll}
disabled={selectAllDisabled}
className="flex items-center gap-1.5 rounded-lg border border-border bg-transparent px-2.5 py-1 text-[12px] text-text-main disabled:cursor-not-allowed disabled:opacity-50"
title={providerText(t, "showAllModels", "Show all")}
>
<span className="material-symbols-outlined text-[16px]">visibility</span>
<span>{providerText(t, "showAllModels", "Show all")}</span>
</button>
<button
onClick={onDeselectAll}
disabled={deselectAllDisabled}
className="flex items-center gap-1.5 rounded-lg border border-border bg-transparent px-2.5 py-1 text-[12px] text-text-main disabled:cursor-not-allowed disabled:opacity-50"
title={providerText(t, "hideAllModels", "Hide all")}
>
<span className="material-symbols-outlined text-[16px]">visibility_off</span>
<span>{providerText(t, "hideAllModels", "Hide all")}</span>
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// ModelRow
// ---------------------------------------------------------------------------
export interface ModelRowProps {
model: { id: string; name?: string; source?: string; isHidden?: boolean };
fullModel: string;
provider: string;
copied?: string;
onCopy: (text: string, key: string) => void;
t: (key: string, values?: Record<string, unknown>) => string;
showDeveloperToggle?: boolean;
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean;
saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void;
getUpstreamHeadersRecord: (protocol: string) => Record<string, string>;
compatDisabled?: boolean;
onToggleHidden?: (modelId: string, hidden: boolean) => Promise<void>;
togglingHidden?: boolean;
onTestModel?: (modelId: string, fullModel: string) => Promise<void>;
testStatus?: "ok" | "error" | null;
testingModel?: boolean;
}
export default function ModelRow({
model,
fullModel,
copied,
onCopy,
t,
showDeveloperToggle = true,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
getUpstreamHeadersRecord,
saveModelCompatFlags,
compatDisabled,
onToggleHidden,
togglingHidden,
onTestModel,
testStatus,
testingModel,
}: ModelRowProps) {
const isHidden = Boolean(model.isHidden);
return (
<div
className={`flex min-w-[220px] max-w-md items-center gap-2 rounded-lg border border-border px-3 py-2 hover:bg-sidebar/50 transition-opacity ${
isHidden ? "opacity-50" : ""
}`}
>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<span
className="material-symbols-outlined shrink-0 text-base"
style={{ color: isHidden ? "var(--color-text-muted)" : undefined }}
>
smart_toy
</span>
<code className="rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted">
{fullModel}
</code>
<ModelSourceBadge source={model.source} />
<button
onClick={() => onCopy(fullModel, `model-${model.id}`)}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
title={t("copyModel")}
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${model.id}` ? "check" : "content_copy"}
</span>
</button>
</div>
<div className="flex shrink-0 items-center gap-1">
{onTestModel && (
<button
onClick={() => onTestModel(model.id, fullModel)}
disabled={testingModel}
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
title={
testingModel
? t("testingModel")
: testStatus === "ok"
? "OK"
: testStatus === "error"
? "Error"
: t("testModel")
}
>
{testingModel ? (
<span className="material-symbols-outlined text-sm animate-spin">
progress_activity
</span>
) : testStatus === "ok" ? (
<span className="material-symbols-outlined text-sm">check_circle</span>
) : testStatus === "error" ? (
<span className="material-symbols-outlined text-sm">error</span>
) : (
<span className="material-symbols-outlined text-sm">play_circle</span>
)}
</button>
)}
{onToggleHidden && (
<button
onClick={() => onToggleHidden(model.id, !isHidden)}
disabled={togglingHidden}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed"
title={
isHidden
? providerText(t, "showModel", "Show model")
: providerText(t, "hideModel", "Hide model")
}
>
<span className="material-symbols-outlined text-sm">
{isHidden ? "visibility_off" : "visibility"}
</span>
</button>
)}
<ModelCompatPopover
t={t}
effectiveModelNormalize={(p) => effectiveModelNormalize(model.id, p)}
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)}
getUpstreamHeadersRecord={getUpstreamHeadersRecord}
onCompatPatch={(protocol, payload) =>
saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } })
}
showDeveloperToggle={showDeveloperToggle}
disabled={compatDisabled}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
/**
* PassthroughModelRow — Issue #3501 Phase 1e
*
* Extracted from ProviderDetailPageClient.tsx. Renders one row in the
* passthrough / compatible models list.
*
* Leaf component: imports from shared, leaf helpers, and sibling components.
* Never imports from ProviderDetailPageClient.
*/
import React from "react";
import { Badge } from "@/shared/components";
import { providerText } from "../providerPageHelpers";
import ModelCompatPopover from "./ModelCompatPopover";
import { ModelSourceBadge, type ModelCompatSavePatch } from "./ModelRow";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
export interface PassthroughModelRowProps {
modelId: string;
fullModel: string;
source?: string;
isFree?: boolean;
isHidden?: boolean;
copied?: string;
onCopy: (text: string, key: string) => void;
onDeleteAlias?: () => void;
t: (key: string, values?: Record<string, unknown>) => string;
showDeveloperToggle?: boolean;
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean;
saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void;
getUpstreamHeadersRecord: (protocol: string) => Record<string, string>;
compatDisabled?: boolean;
onToggleHidden?: (modelId: string, hidden: boolean) => Promise<void>;
togglingHidden?: boolean;
onTestModel?: (modelId: string, fullModel: string) => Promise<void>;
testStatus?: "ok" | "error" | null;
testingModel?: boolean;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function PassthroughModelRow({
modelId,
fullModel,
source,
isFree,
isHidden,
copied,
onCopy,
onDeleteAlias,
t,
showDeveloperToggle = true,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
getUpstreamHeadersRecord,
saveModelCompatFlags,
compatDisabled,
onToggleHidden,
togglingHidden,
onTestModel,
testStatus,
testingModel,
}: PassthroughModelRowProps) {
return (
<div
className={`flex min-w-0 flex-col gap-2 rounded-lg border border-border px-3.5 py-3 transition-opacity hover:bg-sidebar/50 ${
isHidden ? "opacity-50" : ""
}`}
>
<div className="flex min-w-0 items-center gap-2">
<span
className="material-symbols-outlined shrink-0 text-base text-text-muted"
style={{ color: isHidden ? "var(--color-text-muted)" : undefined }}
>
smart_toy
</span>
<code
className="min-w-0 truncate rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted"
title={fullModel}
>
{fullModel}
</code>
</div>
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<ModelSourceBadge source={source} />
{isFree && (
<Badge variant="success" className="shrink-0 px-1.5 py-0 text-[10px]">
{providerText(t, "freeBadge", "Free")}
</Badge>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => onCopy(fullModel, `model-${modelId}`)}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
title={t("copyModel")}
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${modelId}` ? "check" : "content_copy"}
</span>
</button>
{onTestModel && (
<button
onClick={() => onTestModel(modelId, fullModel)}
disabled={testingModel}
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
title={
testingModel
? t("testingModel")
: testStatus === "ok"
? "OK"
: testStatus === "error"
? "Error"
: t("testModel")
}
>
{testingModel ? (
<span className="material-symbols-outlined text-sm animate-spin">
progress_activity
</span>
) : testStatus === "ok" ? (
<span className="material-symbols-outlined text-sm">check_circle</span>
) : testStatus === "error" ? (
<span className="material-symbols-outlined text-sm">error</span>
) : (
<span className="material-symbols-outlined text-sm">play_circle</span>
)}
</button>
)}
{onToggleHidden && (
<button
onClick={() => onToggleHidden(modelId, !isHidden)}
disabled={togglingHidden}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
title={
isHidden
? providerText(t, "showModel", "Show model")
: providerText(t, "hideModel", "Hide model")
}
>
<span className="material-symbols-outlined text-sm">
{isHidden ? "visibility_off" : "visibility"}
</span>
</button>
)}
<ModelCompatPopover
t={t}
effectiveModelNormalize={(p) => effectiveModelNormalize(modelId, p)}
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)}
getUpstreamHeadersRecord={getUpstreamHeadersRecord}
onCompatPatch={(protocol, payload) =>
saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } })
}
showDeveloperToggle={showDeveloperToggle}
compact
disabled={compatDisabled}
/>
{onDeleteAlias && (
<button
onClick={onDeleteAlias}
className="rounded p-1 text-red-500 hover:bg-red-50"
title={t("removeModel")}
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,421 @@
"use client";
/**
* PassthroughModelsSection — Issue #3501 Phase 1e
*
* Extracted from ProviderDetailPageClient.tsx. Renders the full "passthrough
* models" panel for non-compatible providers (OpenRouter, etc.).
*
* Preserves the Bug #3610 fix intact:
* - autoHideFailed is threaded from the outer component (shared checkbox).
* - buildPassthroughTestBody / shouldSwitchToVisibleFilter come from the
* leaf helper providerPageHelpers.ts.
*
* Never imports from ProviderDetailPageClient.
*/
import React, { useState, useMemo } from "react";
import { Button } from "@/shared/components";
import { matchesModelCatalogQuery, normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch";
import { useNotificationStore } from "@/store/notificationStore";
import {
buildCompatMap,
providerText,
buildPassthroughTestBody,
shouldSwitchToVisibleFilter,
type CompatModelRow,
type CompatByProtocolMap,
} from "../providerPageHelpers";
import { ModelVisibilityToolbar } from "./ModelRow";
import PassthroughModelRow from "./PassthroughModelRow";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
export type ModelCompatSavePatchPassthrough = {
normalizeToolCallId?: boolean;
preserveDeveloperRole?: boolean;
preserveOpenAIDeveloperRole?: boolean;
};
export interface PassthroughModelsSectionProps {
providerAlias: string;
modelAliases: Record<string, string>;
availableModels?: CompatModelRow[];
customModels?: CompatModelRow[];
description: string;
inputLabel: string;
inputPlaceholder: string;
copied?: string;
onCopy: (text: string, key: string) => void;
onSetAlias: (modelId: string, alias: string) => Promise<void>;
onDeleteAlias: (alias: string) => void;
t: (key: string, values?: Record<string, unknown>) => string;
effectiveModelNormalize: (alias: string) => boolean;
effectiveModelPreserveDeveloper: (alias: string) => boolean;
getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record<string, string>;
saveModelCompatFlags: (
modelId: string,
flags: ModelCompatSavePatchPassthrough
) => Promise<void>;
compatSavingModelId?: string;
isModelHidden: (modelId: string) => boolean;
onToggleHidden: (modelId: string, hidden: boolean) => Promise<void>;
onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise<void>;
bulkTogglePending?: boolean;
togglingModelId?: string | null;
onTestModel?: (modelId: string, fullModel: string) => Promise<void>;
modelTestStatus?: Record<string, "ok" | "error" | null>;
testingModelId?: string | null;
providerId: string;
connectionId: string;
/** Controlled from the outer component so both sections share one checkbox (#3610). */
autoHideFailed?: boolean;
onAutoHideFailedChange?: (v: boolean) => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function PassthroughModelsSection({
providerAlias,
modelAliases,
availableModels = [],
customModels = [],
description,
inputLabel,
inputPlaceholder,
copied,
onCopy,
onSetAlias,
onDeleteAlias,
t,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
getUpstreamHeadersRecord,
saveModelCompatFlags,
compatSavingModelId,
isModelHidden,
onToggleHidden,
onBulkToggleHidden,
bulkTogglePending,
togglingModelId,
onTestModel,
modelTestStatus,
testingModelId,
providerId,
connectionId,
// Bug #3610 fix 1: use the prop value when provided; fall back to local state only
// when the outer component does not pass the prop (backward-compat / standalone use).
autoHideFailed: autoHideFailedProp,
onAutoHideFailedChange,
}: PassthroughModelsSectionProps) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
const [modelFilter, setModelFilter] = useState("");
const [testingAll, setTestingAll] = useState(false);
const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null);
const [localAutoHideFailed, setLocalAutoHideFailed] = useState(true);
const autoHideFailed = autoHideFailedProp !== undefined ? autoHideFailedProp : localAutoHideFailed;
const setAutoHideFailed = onAutoHideFailedChange ?? setLocalAutoHideFailed;
const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all");
const notify = useNotificationStore();
const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]);
const handleTestAll = async () => {
const modelsToTest = filteredModels.filter((m) => !m.isHidden);
if (modelsToTest.length === 0) {
notify.error(providerText(t, "noModelsToTest", "No models to test"));
return;
}
setTestingAll(true);
setTestProgress({ done: 0, total: modelsToTest.length });
let ok = 0;
let error = 0;
let hiddenCount = 0;
for (const model of modelsToTest) {
try {
const result: {
results?: Record<
string,
{
status?: "ok" | "error";
rateLimited?: boolean;
isTimeout?: boolean;
error?: string;
}
>;
} = await fetch("/api/models/test-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
// Bug #3610 fix 2: pass autoHideFailed so the server persists the hide
body: JSON.stringify(
buildPassthroughTestBody({
providerId,
connectionId,
modelId: model.modelId,
autoHideFailed,
})
),
}).then((r) => r.json());
const entry = result.results?.[model.modelId];
if (entry?.status === "ok") {
ok++;
} else {
error++;
if (autoHideFailed && !entry?.rateLimited && !entry?.isTimeout) {
await onToggleHidden(model.modelId, true);
hiddenCount++;
}
}
} catch (e) {
error++;
}
setTestProgress((prev) => (prev ? { done: prev.done + 1, total: prev.total } : null));
}
notify.info(providerText(t, "testAllResults", "{ok} ok, {error} error", { ok, error }));
if (hiddenCount > 0) {
notify.info(providerText(t, "testAllFailedHidden", "{count} hidden", { count: hiddenCount }));
// Bug #3610 fix 3: switch to "visible" filter so hidden models disappear on-screen
if (shouldSwitchToVisibleFilter({ autoHideFailed, hiddenCount })) {
setVisibilityFilter("visible");
}
}
setTestingAll(false);
setTestProgress(null);
};
const providerAliases = useMemo(
() =>
Object.entries(modelAliases).filter(([, model]: [string, any]) =>
(model as string).startsWith(`${providerAlias}/`)
),
[modelAliases, providerAlias]
);
const allModels = useMemo(() => {
const prefix = `${providerAlias}/`;
const aliasByModelId = new Map<string, string>();
const fullModelByModelId = new Map<string, string>();
const rows: Array<{
modelId: string;
fullModel: string;
alias: string | null;
displayName: string;
source: string;
isFree: boolean;
isHidden: boolean;
}> = [];
const seenModelIds = new Set<string>();
for (const [alias, fullModel] of providerAliases) {
const fmStr = fullModel as string;
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
aliasByModelId.set(modelId, alias as string);
fullModelByModelId.set(modelId, fmStr);
}
const addModel = (model: CompatModelRow, source: string) => {
if (!model?.id || seenModelIds.has(model.id)) return;
const fullModel = fullModelByModelId.get(model.id) || `${providerAlias}/${model.id}`;
rows.push({
modelId: model.id,
fullModel,
alias: aliasByModelId.get(model.id) || null,
displayName: model.name || model.id,
source,
isFree:
Boolean((model as any).free) ||
model.id.endsWith(":free") ||
/\bgr[aá]tis\b|\bfree\b/i.test(model.name || ""),
isHidden: isModelHidden(model.id),
});
seenModelIds.add(model.id);
};
for (const model of availableModels) {
addModel(model, "imported");
}
for (const model of customModels) {
addModel(
model,
normalizeModelCatalogSource(model.source) === "imported" ? "imported" : "custom"
);
}
for (const [alias, fullModel] of providerAliases) {
const fmStr = fullModel as string;
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
if (!modelId || seenModelIds.has(modelId)) continue;
const customModel = customModelMap.get(modelId);
rows.push({
modelId,
fullModel: fmStr,
alias: alias as string,
displayName: alias as string,
source: customModel ? customModel.source || "custom" : "alias",
isFree:
modelId.endsWith(":free") ||
Boolean((customModel as any)?.free) ||
/\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || ""),
isHidden: isModelHidden(modelId),
});
seenModelIds.add(modelId);
}
return rows;
}, [
availableModels,
customModelMap,
customModels,
isModelHidden,
providerAlias,
providerAliases,
]);
const filteredModels = allModels.filter((model) => {
const matchesQuery = matchesModelCatalogQuery(modelFilter, {
modelId: model.modelId,
modelName: model.displayName,
alias: model.alias,
source: model.source,
});
const matchesVisibility =
visibilityFilter === "all"
? true
: visibilityFilter === "visible"
? !model.isHidden
: model.isHidden;
return matchesQuery && matchesVisibility;
});
const activeCount = allModels.filter((model) => !model.isHidden).length;
// Generate default alias from modelId (last part after /)
const generateDefaultAlias = (modelId: string) => {
const parts = modelId.split("/");
return parts[parts.length - 1];
};
const handleAdd = async () => {
if (!newModel.trim() || adding) return;
const modelId = newModel.trim();
const defaultAlias = generateDefaultAlias(modelId);
// Check if alias already exists
if (modelAliases[defaultAlias]) {
alert(t("aliasExistsAlert", { alias: defaultAlias }));
return;
}
setAdding(true);
try {
await onSetAlias(modelId, defaultAlias);
setNewModel("");
} catch (error) {
console.error("Error adding model:", error);
} finally {
setAdding(false);
}
};
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">{description}</p>
{/* Add new model */}
<div className="flex items-end gap-2">
<div className="flex-1">
<label htmlFor="new-model-input" className="text-xs text-text-muted mb-1 block">
{inputLabel}
</label>
<input
id="new-model-input"
type="text"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder={inputPlaceholder}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</div>
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModel.trim() || adding}>
{adding ? t("adding") : t("add")}
</Button>
</div>
{/* Models list */}
{allModels.length > 0 && (
<div className="flex flex-col gap-3">
<ModelVisibilityToolbar
t={t}
filterValue={modelFilter}
onFilterChange={setModelFilter}
activeCount={activeCount}
totalCount={allModels.length}
onSelectAll={() =>
onBulkToggleHidden(
filteredModels.map((m) => m.modelId),
false
)
}
onDeselectAll={() =>
onBulkToggleHidden(
filteredModels.map((m) => m.modelId),
true
)
}
selectAllDisabled={bulkTogglePending || filteredModels.length === 0}
deselectAllDisabled={bulkTogglePending || filteredModels.length === 0}
onTestAll={handleTestAll}
testingAll={testingAll}
visibilityFilter={visibilityFilter}
onVisibilityFilterChange={setVisibilityFilter}
autoHideFailed={autoHideFailed}
onAutoHideFailedChange={setAutoHideFailed}
/>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{filteredModels.map(({ modelId, fullModel, alias, isHidden, source, isFree }) => (
<PassthroughModelRow
key={fullModel as string}
modelId={modelId}
fullModel={fullModel}
source={source}
isFree={isFree}
isHidden={isHidden}
copied={copied}
onCopy={onCopy}
onDeleteAlias={source === "alias" && alias ? () => onDeleteAlias(alias) : undefined}
t={t}
showDeveloperToggle
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)}
saveModelCompatFlags={saveModelCompatFlags}
compatDisabled={compatSavingModelId === modelId}
onToggleHidden={onToggleHidden}
togglingHidden={togglingModelId === modelId}
onTestModel={onTestModel}
testStatus={modelTestStatus?.[modelId] || null}
testingModel={testingModelId === modelId}
/>
))}
</div>
{filteredModels.length === 0 && modelFilter && (
<p className="py-2 text-sm text-text-muted">
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
filter: modelFilter,
})}
</p>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
/**
* useModelCompatState — Issue #3501 Phase 1e
*
* Owns all model-compat derivations (buildCompatMap, isModelHidden,
* effectiveNormalizeForProtocol, effectivePreserveForProtocol,
* anyNormalizeCompatBadge, anyNoPreserveCompatBadge) that used to be
* inlined in the god-component.
*
* Leaf module: imports ONLY from providerPageHelpers and React.
* No import from ProviderDetailPageClient → zero cycle risk.
*/
import { useMemo, useCallback } from "react";
import { MODEL_COMPAT_PROTOCOL_KEYS } from "@/shared/constants/modelCompat";
import {
buildCompatMap,
isModelHiddenFn,
effectiveNormalizeForProtocol,
effectivePreserveForProtocol,
anyNormalizeCompatBadge,
anyNoPreserveCompatBadge,
effectiveUpstreamHeadersForProtocol,
type CompatModelRow,
type CompatModelMap,
} from "../providerPageHelpers";
export interface ModelCompatState {
/** The computed custom-model map (memoised). */
customMap: CompatModelMap;
/** The computed override map (memoised). */
overrideMap: CompatModelMap;
/** Stable callback: is the given model hidden? */
isModelHidden: (modelId: string) => boolean;
/** Stable callback: effective normalize flag for (modelId, protocol). */
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
/** Stable callback: effective preserve-developer flag for (modelId, protocol). */
effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean;
/** Stable callback: upstream-headers record for (modelId, protocol). */
getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record<string, string>;
/** Stable callback: should the normalize compat badge be shown? */
anyNormalizeCompatBadge: (modelId: string) => boolean;
/** Stable callback: should the no-preserve compat badge be shown? */
anyNoPreserveCompatBadge: (modelId: string) => boolean;
}
/**
* Hook that derives stable compat callbacks from raw model-meta arrays.
*
* @param customModels The `modelMeta.customModels` array from page state.
* @param modelCompatOverrides The `modelMeta.modelCompatOverrides` array from page state.
*/
export function useModelCompatState(
customModels: CompatModelRow[],
modelCompatOverrides: Array<CompatModelRow & { id: string }>
): ModelCompatState {
const customMap = useMemo(() => buildCompatMap(customModels), [customModels]);
const overrideMap = useMemo(() => buildCompatMap(modelCompatOverrides), [modelCompatOverrides]);
const isModelHidden = useCallback(
(modelId: string) => isModelHiddenFn(modelId, customMap, overrideMap),
[customMap, overrideMap]
);
const effectiveModelNormalize = useCallback(
(modelId: string, protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]) =>
effectiveNormalizeForProtocol(modelId, protocol, customMap, overrideMap),
[customMap, overrideMap]
);
const effectiveModelPreserveDeveloper = useCallback(
(modelId: string, protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]) =>
effectivePreserveForProtocol(modelId, protocol, customMap, overrideMap),
[customMap, overrideMap]
);
const getUpstreamHeadersRecord = useCallback(
(modelId: string, protocol: string) =>
effectiveUpstreamHeadersForProtocol(modelId, protocol, customMap, overrideMap),
[customMap, overrideMap]
);
const anyNormalizeCompatBadgeFn = useCallback(
(modelId: string) => anyNormalizeCompatBadge(modelId, customMap, overrideMap),
[customMap, overrideMap]
);
const anyNoPreserveCompatBadgeFn = useCallback(
(modelId: string) => anyNoPreserveCompatBadge(modelId, customMap, overrideMap),
[customMap, overrideMap]
);
return {
customMap,
overrideMap,
isModelHidden,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
getUpstreamHeadersRecord,
anyNormalizeCompatBadge: anyNormalizeCompatBadgeFn,
anyNoPreserveCompatBadge: anyNoPreserveCompatBadgeFn,
};
}

View File

@@ -439,6 +439,121 @@ export function anyUpstreamHeadersBadge(
return false;
}
// ---------------------------------------------------------------------------
// Model-compat compute helpers (Phase 1e — moved from god-component).
// These are pure functions that derive effective compat state from the two
// maps (customModels + modelCompatOverrides). They live here so both the
// page client AND extracted components can import them without a cycle.
// ---------------------------------------------------------------------------
export function buildCompatMap(rows: CompatModelRow[]): CompatModelMap {
const m = new Map<string, CompatModelRow>();
for (const r of rows) if (r.id) m.set(r.id, r);
return m;
}
export function isModelHiddenFn(
modelId: string,
customMap: CompatModelMap,
overrideMap: CompatModelMap
): boolean {
const c = customMap.get(modelId);
if (c && Object.prototype.hasOwnProperty.call(c, "isHidden")) {
return Boolean(c.isHidden);
}
const o = overrideMap.get(modelId);
if (o && Object.prototype.hasOwnProperty.call(o, "isHidden")) {
return Boolean(o.isHidden);
}
return false;
}
export function effectiveNormalizeForProtocol(
modelId: string,
protocol: string,
customMap: CompatModelMap,
overrideMap: CompatModelMap
): boolean {
const c = customMap.get(modelId);
const o = overrideMap.get(modelId);
const pc = getProtoSlice(c, o, protocol);
if (pc && Object.prototype.hasOwnProperty.call(pc, "normalizeToolCallId")) {
return Boolean(pc.normalizeToolCallId);
}
if (c?.normalizeToolCallId) return true;
return Boolean(o?.normalizeToolCallId);
}
export function effectivePreserveForProtocol(
modelId: string,
protocol: string,
customMap: CompatModelMap,
overrideMap: CompatModelMap
): boolean {
const c = customMap.get(modelId);
const o = overrideMap.get(modelId);
const pc = getProtoSlice(c, o, protocol);
if (pc && Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole")) {
return Boolean(pc.preserveOpenAIDeveloperRole);
}
if (c && Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole")) {
return Boolean(c.preserveOpenAIDeveloperRole);
}
if (o && Object.prototype.hasOwnProperty.call(o, "preserveOpenAIDeveloperRole")) {
return Boolean(o.preserveOpenAIDeveloperRole);
}
return true;
}
export function anyNormalizeCompatBadge(
modelId: string,
customMap: CompatModelMap,
overrideMap: CompatModelMap
): boolean {
const c = customMap.get(modelId);
const o = overrideMap.get(modelId);
if (c?.normalizeToolCallId || o?.normalizeToolCallId) return true;
for (const p of MODEL_COMPAT_PROTOCOL_KEYS) {
const pc = getProtoSlice(c, o, p);
if (pc?.normalizeToolCallId) return true;
}
return false;
}
export function anyNoPreserveCompatBadge(
modelId: string,
customMap: CompatModelMap,
overrideMap: CompatModelMap
): boolean {
const c = customMap.get(modelId);
const o = overrideMap.get(modelId);
if (
c &&
Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole") &&
c.preserveOpenAIDeveloperRole === false
) {
return true;
}
if (
o &&
Object.prototype.hasOwnProperty.call(o, "preserveOpenAIDeveloperRole") &&
o.preserveOpenAIDeveloperRole === false
) {
return true;
}
for (const p of MODEL_COMPAT_PROTOCOL_KEYS) {
const pc = getProtoSlice(c, o, p);
if (
pc &&
Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole") &&
pc.preserveOpenAIDeveloperRole === false
) {
return true;
}
}
return false;
}
// ---------------------------------------------------------------------------
// Codex helpers + consts (Phase 2b)
// ---------------------------------------------------------------------------
@@ -652,6 +767,41 @@ export const ERROR_TYPE_LABELS: Record<
credits_exhausted: { labelKey: "errorTypeCreditsExhausted", variant: "warning" },
};
// ---------------------------------------------------------------------------
// formatProviderModelsErrorResponse — shared error formatter for provider-models
// API calls. Used by both the page client and CustomModelsSection.
// ---------------------------------------------------------------------------
type ProviderModelsApiErrorBody = {
error?: {
message?: string;
details?: Array<{ field?: string; message?: string }>;
};
};
export async function formatProviderModelsErrorResponse(res: Response): Promise<string> {
try {
const data = (await res.json()) as ProviderModelsApiErrorBody;
const err = data?.error;
if (Array.isArray(err?.details) && err.details.length > 0) {
return err.details
.map((d) => {
const f = typeof d.field === "string" && d.field ? d.field : "?";
const m = typeof d.message === "string" ? d.message : "";
return m ? `${f}: ${m}` : f;
})
.join("; ");
}
if (typeof err?.message === "string" && err.message.trim()) {
return err.message.trim();
}
} catch {
/* ignore */
}
const st = res.statusText?.trim();
return st || `HTTP ${res.status}`;
}
// ---------------------------------------------------------------------------
// formatTimeAgo — used in EditConnectionModal's extra-key health display
// ---------------------------------------------------------------------------