feat(dashboard): import providers from CSV/JSON file (#6836)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 15:03:48 -03:00
parent e6f81d827e
commit 1874998ad6
13 changed files with 998 additions and 1 deletions

View File

@@ -0,0 +1 @@
- feat(dashboard): import multiple, possibly different providers from a CSV/JSON file — per-row validation, a checklist to pick which parsed rows to import, and a new `POST /api/providers/import` route with partial-failure results (#6836)

View File

@@ -435,6 +435,8 @@ Response example:
| `/api/providers/[id]/test` | POST | Test provider connection |
| `/api/providers/[id]/models` | GET | List provider models |
| `/api/providers/validate` | POST | Validate provider config |
| `/api/providers/bulk` | POST | Bulk-add API keys for ONE provider |
| `/api/providers/import` | POST | Import a heterogeneous provider LIST from a parsed CSV/JSON file (#6836); per-row partial-failure results |
| `/api/provider-nodes*` | Various | Provider node management |
| `/api/provider-models` | GET/POST/PATCH/DELETE | Custom models (add, update, hide/show, delete) |

View File

@@ -0,0 +1,172 @@
"use client";
import { useTranslations } from "next-intl";
import { Button, Modal } from "@/shared/components";
import type { ParsedProviderImportEntry, ProviderImportParseError } from "./parseProviderImportFile";
import { useImportProvidersFromFile } from "./useImportProvidersFromFile";
interface ImportProvidersFromFileModalProps {
isOpen: boolean;
onClose: () => void;
onImported: () => Promise<void>;
}
type Translator = (key: string, values?: Record<string, unknown>) => string;
const PARSE_ERROR_REASON_KEYS = [
"importErrorMissingProvider",
"importErrorMissingName",
"importErrorMissingApiKey",
"importErrorInvalidPriority",
"importErrorMalformedRow",
"importErrorNotArray",
] as const;
/** Per-row parse error list. Split out of the modal to keep it under the LOC ratchet. */
function ParseErrorsList({ errors, t }: { errors: ProviderImportParseError[]; t: Translator }) {
if (errors.length === 0) return null;
return (
<div className="max-h-28 overflow-y-auto rounded border border-red-500/30 bg-red-500/10 p-2">
{errors.map((err, idx) => (
<div key={idx} className="text-xs text-red-400">
{t("importFromFileErrorLine", {
line: err.line,
reason: t(
PARSE_ERROR_REASON_KEYS.includes(err.reason as (typeof PARSE_ERROR_REASON_KEYS)[number])
? err.reason
: "importErrorMalformedRow"
),
})}
</div>
))}
</div>
);
}
interface EntriesTableProps {
entries: ParsedProviderImportEntry[];
selected: Set<number>;
onToggleRow: (idx: number) => void;
onToggleAll: (checked: boolean) => void;
t: Translator;
}
/** Parsed-rows selection checklist (acceptance criterion #3 of #6836). */
function EntriesTable({ entries, selected, onToggleRow, onToggleAll, t }: EntriesTableProps) {
if (entries.length === 0) return null;
return (
<>
<div className="text-xs text-text-muted">
{t("importFromFileSelectHint", { count: selected.size, total: entries.length })}
</div>
<div className="overflow-x-auto max-h-64 overflow-y-auto rounded border border-border">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-text-muted border-b border-border bg-bg-subtle sticky top-0">
<th className="py-1.5 px-2">
<input
type="checkbox"
checked={selected.size === entries.length}
onChange={(e) => onToggleAll(e.target.checked)}
/>
</th>
<th className="py-1.5 px-2">{t("importFromFileColProvider")}</th>
<th className="py-1.5 px-2">{t("importFromFileColName")}</th>
<th className="py-1.5 px-2">{t("importFromFileColBaseUrl")}</th>
</tr>
</thead>
<tbody>
{entries.map((entry, idx) => (
<tr key={idx} className="border-b border-border/40">
<td className="py-1 px-2">
<input type="checkbox" checked={selected.has(idx)} onChange={() => onToggleRow(idx)} />
</td>
<td className="py-1 px-2 font-mono text-text-muted">{entry.provider}</td>
<td className="py-1 px-2 font-medium text-text-main">{entry.name}</td>
<td className="py-1 px-2 text-text-muted">{entry.baseUrl || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
interface FilePickerRowProps {
fileInputRef: React.RefObject<HTMLInputElement | null>;
fileName: string;
onFile: (file: File) => void;
t: Translator;
}
/** File-input trigger + chosen filename display. */
function FilePickerRow({ fileInputRef, fileName, onFile, t }: FilePickerRowProps) {
return (
<div className="flex items-center gap-3">
<input
ref={fileInputRef}
type="file"
accept=".csv,.json,text/csv,application/json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onFile(file);
}}
/>
<Button size="sm" variant="secondary" icon="upload_file" onClick={() => fileInputRef.current?.click()}>
{t("importFromFileChoose")}
</Button>
{fileName && <span className="text-xs text-text-muted font-mono">{fileName}</span>}
</div>
);
}
/**
* Wizard step: upload a CSV/JSON file listing MULTIPLE, possibly different providers,
* pick which parsed rows to actually import, then submit them in one batch (#6836).
* Mirrors `ProxyBulkImportModal.tsx`'s parse → review → execute UX. State/handlers live
* in `useImportProvidersFromFile`; presentation-only pieces are split into the small
* components above so this component itself stays under the LOC ratchet.
*/
export function ImportProvidersFromFileModal({
isOpen,
onClose,
onImported,
}: ImportProvidersFromFileModalProps) {
const t = useTranslations("providers");
const s = useImportProvidersFromFile(onImported);
return (
<Modal isOpen={isOpen} onClose={() => s.handleClose(onClose)} title={t("importFromFileTitle")} maxWidth="xl">
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">{t("importFromFileDescription")}</p>
<FilePickerRow fileInputRef={s.fileInputRef} fileName={s.fileName} onFile={s.handleFile} t={t} />
<ParseErrorsList errors={s.errors} t={t} />
<EntriesTable entries={s.entries} selected={s.selected} onToggleRow={s.toggleRow} onToggleAll={s.toggleAll} t={t} />
{s.result && (
<div className="px-3 py-2 rounded border border-emerald-500/30 bg-emerald-500/10 text-sm text-emerald-400">
{t("importFromFileResult", { success: s.result.success, failed: s.result.failed })}
</div>
)}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button size="sm" variant="secondary" onClick={() => s.handleClose(onClose)}>
{t("cancel")}
</Button>
<Button
size="sm"
icon="upload"
onClick={s.handleExecute}
loading={s.importing}
disabled={s.selected.size === 0 || s.importing}
>
{s.importing ? t("importFromFileImporting") : t("importFromFileImport", { count: s.selected.size })}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -42,6 +42,7 @@ interface ProviderSummaryCardProps {
onCategoryChange(category: string | null, freeOnly: boolean): void;
onDisplayModeChange(mode: ProviderDisplayMode): void;
onNewProvider(): void;
onImportFromFile(): void;
searchQuery: string;
setModelSearchQuery(value: string): void;
setSearchQuery(value: string): void;
@@ -96,6 +97,7 @@ export default function ProviderSummaryCard({
onCategoryChange,
onDisplayModeChange,
onNewProvider,
onImportFromFile,
searchQuery,
setModelSearchQuery,
setSearchQuery,
@@ -200,6 +202,9 @@ export default function ProviderSummaryCard({
<Button size="sm" icon="add" onClick={onNewProvider}>
{providerText(t, "onboardingWizardShort", "Onboarding Wizard")}
</Button>
<Button size="sm" variant="secondary" icon="upload_file" onClick={onImportFromFile}>
{providerText(t, "importFromFile", "Import from file")}
</Button>
<button
onClick={() => onBatchTest("all")}
disabled={!!testingMode}

View File

@@ -0,0 +1,185 @@
/**
* Pure parser for the "Import providers from file" wizard step (#6836).
*
* Supported inputs, one heterogeneous provider list per file:
* - CSV: `provider,name,apiKey,baseUrl(optional),priority(optional)` — one row per
* provider connection. A header row (first non-blank/non-comment line whose first
* column is literally "provider", case-insensitive) is detected and skipped.
* - JSON: an array of `{ provider, name, apiKey, baseUrl?, priority? }` objects.
*
* Modeled directly on `parseBulkProxyImport.ts` (same shape: entries + per-row errors +
* skipped count, comment/blank-line skipping for CSV), swapping the row shape for
* provider connections. Deliberately does NOT validate the `provider` id against the
* provider catalog — that check belongs server-side in `bulkImportProviderSchema`
* (`src/shared/validation/schemas/provider.ts`) so the parser stays a pure, dependency-free
* client-side utility.
*/
export type ParsedProviderImportEntry = {
provider: string;
name: string;
apiKey: string;
baseUrl?: string;
priority?: number;
};
export type ProviderImportParseError = {
line: number;
reason:
| "importErrorMissingProvider"
| "importErrorMissingName"
| "importErrorMissingApiKey"
| "importErrorInvalidPriority"
| "importErrorMalformedRow"
| "importErrorNotArray";
};
export type ProviderImportParseResult = {
entries: ParsedProviderImportEntry[];
errors: ProviderImportParseError[];
skipped: number;
};
const CSV_HEADER_FIRST_COLUMN = "provider";
type PriorityParseResult = { ok: true; priority: number | undefined } | { ok: false };
/**
* Parse+validate the optional `priority` field in isolation — split out of
* `pushParsedEntry` purely to keep that function's cyclomatic complexity under the
* repo's ratchet (each branch here would otherwise count toward the caller).
*/
function parseOptionalPriority(raw: unknown): PriorityParseResult {
if (raw === undefined || raw === null || raw === "") return { ok: true, priority: undefined };
const n = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(n) || n < 1 || n > 100) return { ok: false };
return { ok: true, priority: n };
}
/**
* Validate + normalize one already-split row of raw string fields into an entry,
* pushing to `entries` on success or `errors` on failure. Shared by the CSV and
* JSON code paths so both apply identical field rules.
*/
function pushParsedEntry(
entries: ParsedProviderImportEntry[],
errors: ProviderImportParseError[],
lineNum: number,
raw: { provider?: unknown; name?: unknown; apiKey?: unknown; baseUrl?: unknown; priority?: unknown }
): void {
const provider = typeof raw.provider === "string" ? raw.provider.trim() : "";
const name = typeof raw.name === "string" ? raw.name.trim() : "";
const apiKey = typeof raw.apiKey === "string" ? raw.apiKey.trim() : "";
if (!provider) {
errors.push({ line: lineNum, reason: "importErrorMissingProvider" });
return;
}
if (!name) {
errors.push({ line: lineNum, reason: "importErrorMissingName" });
return;
}
if (!apiKey) {
errors.push({ line: lineNum, reason: "importErrorMissingApiKey" });
return;
}
const priorityResult = parseOptionalPriority(raw.priority);
if (!priorityResult.ok) {
errors.push({ line: lineNum, reason: "importErrorInvalidPriority" });
return;
}
const priority = priorityResult.priority;
const baseUrl = typeof raw.baseUrl === "string" ? raw.baseUrl.trim() : "";
entries.push({
provider,
name,
apiKey,
...(baseUrl ? { baseUrl } : {}),
...(priority !== undefined ? { priority } : {}),
});
}
function parseCsv(text: string): ProviderImportParseResult {
const lines = text.split("\n");
const entries: ParsedProviderImportEntry[] = [];
const errors: ProviderImportParseError[] = [];
let skipped = 0;
let headerSkipped = false;
for (let i = 0; i < lines.length; i++) {
const raw = lines[i].trim();
if (!raw || raw.startsWith("#")) {
skipped++;
continue;
}
const lineNum = i + 1;
const parts = raw.split(",").map((p) => p.trim());
if (!headerSkipped) {
headerSkipped = true;
if ((parts[0] || "").toLowerCase() === CSV_HEADER_FIRST_COLUMN) {
skipped++;
continue;
}
}
if (parts.length < 3) {
errors.push({ line: lineNum, reason: "importErrorMalformedRow" });
continue;
}
const [provider, name, apiKey, baseUrl, priority] = parts;
pushParsedEntry(entries, errors, lineNum, { provider, name, apiKey, baseUrl, priority });
}
return { entries, errors, skipped };
}
function parseJson(text: string): ProviderImportParseResult {
const entries: ParsedProviderImportEntry[] = [];
const errors: ProviderImportParseError[] = [];
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { entries, errors: [{ line: 1, reason: "importErrorMalformedRow" }], skipped: 0 };
}
if (!Array.isArray(parsed)) {
return { entries, errors: [{ line: 1, reason: "importErrorNotArray" }], skipped: 0 };
}
parsed.forEach((row, idx) => {
const lineNum = idx + 1;
if (!row || typeof row !== "object") {
errors.push({ line: lineNum, reason: "importErrorMalformedRow" });
return;
}
const r = row as Record<string, unknown>;
pushParsedEntry(entries, errors, lineNum, {
provider: r.provider,
name: r.name,
apiKey: r.apiKey,
baseUrl: r.baseUrl,
priority: r.priority as number | string | undefined,
});
});
return { entries, errors, skipped: 0 };
}
/**
* Parse a provider import file's text content. `format` is decided by the caller from
* the uploaded file's extension/MIME type ("csv" for `.csv`/text-csv, "json" otherwise).
*/
export function parseProviderImportFile(
text: string,
format: "csv" | "json"
): ProviderImportParseResult {
return format === "json" ? parseJson(text) : parseCsv(text);
}

View File

@@ -0,0 +1,97 @@
import { useRef, useState } from "react";
import {
parseProviderImportFile,
type ParsedProviderImportEntry,
type ProviderImportParseError,
} from "./parseProviderImportFile";
export type ImportResult = { success: number; failed: number; total: number };
/**
* All state + handlers for `ImportProvidersFromFileModal`, split into a hook purely
* to keep the component's own function under the repo's max-lines-per-function ratchet
* (#6836). Behavior is unchanged — this is a pure extraction, not a refactor.
*/
export function useImportProvidersFromFile(onImported: () => Promise<void>) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [fileName, setFileName] = useState("");
const [entries, setEntries] = useState<ParsedProviderImportEntry[]>([]);
const [errors, setErrors] = useState<ProviderImportParseError[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const resetParsed = () => {
setEntries([]);
setErrors([]);
setSelected(new Set());
setResult(null);
};
const handleFile = async (file: File) => {
setFileName(file.name);
resetParsed();
const format = file.name.toLowerCase().endsWith(".json") ? "json" : "csv";
const text = await file.text();
const parsed = parseProviderImportFile(text, format);
setEntries(parsed.entries);
setErrors(parsed.errors);
setSelected(new Set(parsed.entries.map((_, idx) => idx)));
};
const toggleRow = (idx: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
};
const toggleAll = (checked: boolean) => {
setSelected(checked ? new Set(entries.map((_, i) => i)) : new Set());
};
const handleClose = (onClose: () => void) => {
if (importing) return;
setFileName("");
resetParsed();
if (fileInputRef.current) fileInputRef.current.value = "";
onClose();
};
const handleExecute = async () => {
const toImport = entries.filter((_, idx) => selected.has(idx));
if (toImport.length === 0) return;
setImporting(true);
try {
const res = await fetch("/api/providers/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entries: toImport }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
setResult({ success: data.success ?? 0, failed: data.failed ?? 0, total: data.total ?? 0 });
await onImported();
}
} finally {
setImporting(false);
}
};
return {
fileInputRef,
fileName,
entries,
errors,
selected,
importing,
result,
handleFile,
toggleRow,
toggleAll,
handleClose,
handleExecute,
};
}

View File

@@ -42,6 +42,7 @@ import {
} from "@/lib/providers/codexFastTier";
import AddCompatibleProviderModal from "./components/AddCompatibleProviderModal";
import { CategoryDot } from "./components/CategoryDot";
import { ImportProvidersFromFileModal } from "./components/ImportProvidersFromFileModal";
import NoAuthProvidersSection from "./components/NoAuthProvidersSection";
import ProviderCard from "./components/ProviderCard";
import ProviderCountBadge from "./components/ProviderCountBadge";
@@ -181,6 +182,7 @@ export default function ProvidersPage() {
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
const [showAddCcCompatibleModal, setShowAddCcCompatibleModal] = useState(false);
const [showImportFromFileModal, setShowImportFromFileModal] = useState(false);
const [testingMode, setTestingMode] = useState<string | null>(null);
const [testResults, setTestResults] = useState<any>(null);
const [providerDisplayMode, setProviderDisplayMode] = useState<ProviderDisplayMode>("all");
@@ -860,6 +862,7 @@ export default function ProvidersPage() {
}}
onDisplayModeChange={setProviderDisplayMode}
onNewProvider={() => router.push("/dashboard/providers/new")}
onImportFromFile={() => setShowImportFromFileModal(true)}
searchQuery={searchQuery}
setModelSearchQuery={setModelSearchQuery}
setSearchQuery={setSearchQuery}
@@ -1782,6 +1785,11 @@ export default function ProvidersPage() {
}}
/>
)}
<ImportProvidersFromFileModal
isOpen={showImportFromFileModal}
onClose={() => setShowImportFromFileModal(false)}
onImported={async () => setConnections((await loadProviderPageData()).connections)}
/>
{/* Test Results Modal */}
{testResults && (
<div

View File

@@ -0,0 +1,212 @@
import { NextResponse } from "next/server";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import {
getProviderAuditTarget,
summarizeProviderConnectionForAudit,
} from "@/lib/compliance/providerAudit";
import { createProviderConnection, getProviderNodeById, isCloudEnabled } from "@/models";
import { isAnthropicCompatibleProvider, isOpenAICompatibleProvider } from "@/shared/constants/providers";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { bulkImportProviderSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
normalizeProviderSpecificData,
sanitizeProviderSpecificDataForResponse,
} from "@/lib/providers/requestDefaults";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { validateProviderApiKey } from "@/lib/providers/validation";
import { getProxyForLevel, resolveProxyForProvider } from "@/lib/localDb";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
type ImportEntry = {
provider: string;
name: string;
apiKey: string;
baseUrl?: string;
priority?: number;
};
/**
* Resolve the providerSpecificData base object for one entry's provider — mirrors the
* per-provider node resolution in POST /api/providers/bulk, plus an optional per-entry
* `baseUrl` override (the file-import format lets each row point at a different
* OpenAI/Anthropic-compatible endpoint, unlike the single-provider bulk-key route).
*/
async function resolveProviderSpecificData(
entry: ImportEntry
): Promise<Record<string, unknown> | null> {
let base: Record<string, unknown> | null = null;
if (isOpenAICompatibleProvider(entry.provider) || isAnthropicCompatibleProvider(entry.provider)) {
const node: any = await getProviderNodeById(entry.provider);
if (!node) return null;
base = {
prefix: node.prefix,
...(node.apiType ? { apiType: node.apiType } : {}),
baseUrl: entry.baseUrl || node.baseUrl,
nodeName: node.name,
...(node.chatPath ? { chatPath: node.chatPath } : {}),
...(node.modelsPath ? { modelsPath: node.modelsPath } : {}),
};
} else if (entry.baseUrl) {
base = { baseUrl: entry.baseUrl };
}
return normalizeProviderSpecificData(entry.provider, base) || base;
}
async function importOneEntry(
entry: ImportEntry,
validateKeys: boolean
): Promise<{ created: Record<string, unknown> } | { error: string }> {
const providerSpecificData = await resolveProviderSpecificData(entry);
if (
(isOpenAICompatibleProvider(entry.provider) || isAnthropicCompatibleProvider(entry.provider)) &&
!providerSpecificData
) {
return { error: "Provider node not found" };
}
const proxyToUse = validateKeys
? (await resolveProxyForProvider(entry.provider)) ||
(await getProxyForLevel("provider", entry.provider)) ||
(await getProxyForLevel("global")) ||
null
: null;
let testStatus: "active" | "unknown" | "failed" = "unknown";
if (validateKeys) {
const probe = await runWithProxyContext(proxyToUse, () =>
validateProviderApiKey({
provider: entry.provider,
apiKey: entry.apiKey,
providerSpecificData: providerSpecificData || undefined,
})
);
testStatus = probe?.valid ? "active" : "failed";
}
const newConnection = await createProviderConnection({
provider: entry.provider,
authType: "apikey",
name: entry.name,
apiKey: entry.apiKey,
priority: entry.priority || 1,
globalPriority: null,
defaultModel: null,
providerSpecificData,
isActive: true,
testStatus,
});
const safe: Record<string, unknown> = { ...newConnection };
delete safe.apiKey;
if (safe.providerSpecificData) {
safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(
safe.providerSpecificData as Record<string, unknown>
);
}
return { created: safe };
}
async function syncToCloudIfEnabled() {
try {
const cloudEnabled = await isCloudEnabled();
if (!cloudEnabled) return;
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing providers to cloud:", error);
}
}
// POST /api/providers/import — create multiple provider connections from a parsed
// CSV/JSON file, where each row/entry may target a DIFFERENT provider (#6836).
// Partial-failure semantics identical to /api/providers/bulk: every entry succeeds or
// fails independently and the response always returns 200 with per-entry results.
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const auditContext = getAuditRequestContext(request);
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const validation = validateBody(bulkImportProviderSchema, body);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { entries, validateKeys } = validation.data;
const created: Array<Record<string, unknown>> = [];
const errors: Array<{ index: number; name: string; provider: string; message: string }> = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
try {
const result = await importOneEntry(entry, !!validateKeys);
if ("error" in result) {
errors.push({ index: i, name: entry.name, provider: entry.provider, message: result.error });
continue;
}
created.push(result.created);
logAuditEvent({
action: "provider.credentials.created",
actor: "admin",
target: getProviderAuditTarget(result.created),
resourceType: "provider_credentials",
status: "success",
ipAddress: auditContext.ipAddress || undefined,
requestId: auditContext.requestId,
metadata: {
provider: entry.provider,
via: "import",
connection: summarizeProviderConnectionForAudit(result.created),
},
});
} catch (err) {
errors.push({
index: i,
name: entry.name,
provider: entry.provider,
message: sanitizeErrorMessage(err) || "Failed to create connection",
});
}
}
if (created.length > 0) {
await syncToCloudIfEnabled();
}
logAuditEvent({
action: "provider.credentials.bulk_created",
actor: "admin",
resourceType: "provider_credentials",
status: errors.length === entries.length ? "failure" : "success",
ipAddress: auditContext.ipAddress || undefined,
requestId: auditContext.requestId,
metadata: {
via: "import",
total: entries.length,
success: created.length,
failed: errors.length,
},
});
return NextResponse.json(
{
success: created.length,
failed: errors.length,
total: entries.length,
created,
errors,
},
{ status: 200 }
);
}

View File

@@ -3946,6 +3946,24 @@
"addFirstProvider": "Add your first provider",
"addFirstProviderDesc": "Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts.",
"learnMore": "Learn more",
"importFromFile": "Import from file",
"importFromFileTitle": "Import providers from file",
"importFromFileDescription": "Upload a CSV or JSON file listing multiple providers (each row can be a different provider type). Review the parsed rows below and pick which ones to import.",
"importFromFileChoose": "Choose file",
"importFromFileSelectHint": "{count} of {total} selected",
"importFromFileColProvider": "Provider",
"importFromFileColName": "Name",
"importFromFileColBaseUrl": "Base URL",
"importFromFileErrorLine": "Row {line}: {reason}",
"importErrorMissingProvider": "missing provider",
"importErrorMissingName": "missing name",
"importErrorMissingApiKey": "missing API key",
"importErrorInvalidPriority": "invalid priority (must be 1-100)",
"importErrorMalformedRow": "malformed row",
"importErrorNotArray": "file must contain a JSON array",
"importFromFileImporting": "Importing…",
"importFromFileImport": "Import {count} providers",
"importFromFileResult": "Imported {success} providers ({failed} failed)",
"adaptaTutorial": {
"title": "How to connect Adapta Web",
"introPrefix": "Adapta authenticates through Clerk. The token",

View File

@@ -3946,6 +3946,24 @@
"addFirstProvider": "Adicione seu primeiro provedor",
"addFirstProviderDesc": "Conecte um provedor de IA para começar a rotear solicitações por meio do OmniRoute. Você pode usar provedores gratuitos, chaves de API ou contas OAuth.",
"learnMore": "Saiba mais",
"importFromFile": "Importar de arquivo",
"importFromFileTitle": "Importar provedores de arquivo",
"importFromFileDescription": "Envie um arquivo CSV ou JSON listando vários provedores (cada linha pode ser um tipo de provedor diferente). Revise as linhas processadas abaixo e escolha quais importar.",
"importFromFileChoose": "Escolher arquivo",
"importFromFileSelectHint": "{count} de {total} selecionados",
"importFromFileColProvider": "Provedor",
"importFromFileColName": "Nome",
"importFromFileColBaseUrl": "URL base",
"importFromFileErrorLine": "Linha {line}: {reason}",
"importErrorMissingProvider": "provedor ausente",
"importErrorMissingName": "nome ausente",
"importErrorMissingApiKey": "chave de API ausente",
"importErrorInvalidPriority": "prioridade inválida (deve ser 1-100)",
"importErrorMalformedRow": "linha malformada",
"importErrorNotArray": "o arquivo deve conter um array JSON",
"importFromFileImporting": "Importando…",
"importFromFileImport": "Importar {count} provedores",
"importFromFileResult": "{success} provedores importados ({failed} falharam)",
"adaptaTutorial": {
"title": "__MISSING__:How to connect Adapta Web",
"introPrefix": "__MISSING__:Adapta authenticates through Clerk. The token",

View File

@@ -6,7 +6,12 @@ import {
import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints";
import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize";
import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
import {
providerAllowsOptionalApiKey,
isOpenAICompatibleProvider,
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
import { isManagedProviderConnectionId } from "@/lib/providers/catalog";
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
import {
isForbiddenUpstreamHeaderName,
@@ -148,6 +153,38 @@ export const bulkCreateProviderSchema = z
}
});
// ──── Heterogeneous Provider Import Schema (#6836) ────
// #6836: unlike `bulkCreateProviderSchema` (many keys, ONE provider type per request),
// this schema backs a file (CSV/JSON) import of a heterogeneous LIST of providers —
// each entry carries its OWN `provider` id, validated individually here so the route
// can return per-row partial-failure results (same contract as /api/providers/bulk).
export const bulkImportProviderSchema = z.object({
entries: z
.array(
z.object({
provider: z
.string()
.min(1, "provider is required")
.max(100)
.refine(
(id) =>
isManagedProviderConnectionId(id) ||
isOpenAICompatibleProvider(id) ||
isAnthropicCompatibleProvider(id),
{ message: "Unknown or unsupported provider" }
),
name: z.string().min(1, "name is required").max(200),
apiKey: z.string().min(1, "apiKey is required").max(MAX_PROVIDER_CREDENTIAL_LENGTH),
baseUrl: z.string().trim().max(2000).optional(),
priority: z.number().int().min(1).max(100).optional(),
})
)
.min(1, "entries must contain at least 1 item")
.max(200, "entries must contain at most 200 items"),
validateKeys: z.boolean().optional(),
});
// ──── Bulk Web-Session Import Schema ────
export const bulkWebSessionImportSchema = z.object({

View File

@@ -0,0 +1,122 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// #6836 — POST /api/providers/import: heterogeneous file-driven provider import.
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-providers-import-route-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../../src/lib/db/core.ts");
const importRoute = await import("../../../src/app/api/providers/import/route.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
function postImport(body: unknown) {
return importRoute.POST(
new Request("http://localhost/api/providers/import", {
method: "POST",
body: JSON.stringify(body),
})
);
}
test("providers import route returns 400 for invalid JSON", async () => {
await resetStorage();
const response = await importRoute.POST(
new Request("http://localhost/api/providers/import", { method: "POST", body: "not json" })
);
assert.equal(response.status, 400);
});
test("providers import route returns 400 for empty entries", async () => {
await resetStorage();
const response = await postImport({ entries: [] });
assert.equal(response.status, 400);
});
test("providers import route rejects an unknown provider id at the schema layer", async () => {
await resetStorage();
const response = await postImport({
entries: [{ provider: "totally-not-a-real-provider", name: "x", apiKey: "sk-1" }],
});
assert.equal(response.status, 400);
});
test("providers import route requires name and apiKey per entry", async () => {
await resetStorage();
const response = await postImport({
entries: [{ provider: "openai", name: "", apiKey: "sk-1" }],
});
assert.equal(response.status, 400);
});
test("providers import route imports a heterogeneous list with 200 + per-row results", async () => {
await resetStorage();
const response = await postImport({
entries: [
{ provider: "openai", name: "Prod OpenAI", apiKey: "sk-openai-1" },
{ provider: "anthropic", name: "Prod Anthropic", apiKey: "sk-anthropic-1" },
],
});
assert.equal(response.status, 200);
const body = (await response.json()) as any;
assert.equal(body.total, 2);
assert.equal(body.success, 2);
assert.equal(body.failed, 0);
assert.equal(body.created.length, 2);
// Never echo the raw apiKey back.
assert.ok(body.created.every((c: any) => c.apiKey === undefined));
assert.deepEqual(
body.created.map((c: any) => c.provider).sort(),
["anthropic", "openai"]
);
});
test("providers import route: partial-failure — unresolvable compatible node fails its own row only", async () => {
await resetStorage();
const response = await postImport({
entries: [
{ provider: "openai", name: "Prod OpenAI", apiKey: "sk-openai-1" },
{
provider: "openai-compatible-unknown-node-id",
name: "Bad Compatible",
apiKey: "sk-bad-1",
},
],
});
assert.equal(response.status, 200);
const body = (await response.json()) as any;
assert.equal(body.total, 2);
assert.equal(body.success, 1);
assert.equal(body.failed, 1);
assert.equal(body.errors[0].index, 1);
assert.equal(body.errors[0].message, "Provider node not found");
// Error responses/messages must never leak a raw stack trace (Hard Rule #12).
assert.ok(!JSON.stringify(body).includes(" at /"));
});
test("providers import route applies a per-entry baseUrl override for compatible providers", async () => {
await resetStorage();
// openai-compatible providers require a registered node; without one the row fails
// cleanly (asserted above). This test only proves the schema/route accept and forward
// a per-entry baseUrl for a first-party (non-compatible) provider without erroring.
const response = await postImport({
entries: [{ provider: "openai", name: "Prod", apiKey: "sk-1", baseUrl: "https://example.com" }],
});
assert.equal(response.status, 200);
const body = (await response.json()) as any;
assert.equal(body.success, 1);
assert.equal(body.created[0].providerSpecificData?.baseUrl, "https://example.com");
});

View File

@@ -0,0 +1,120 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseProviderImportFile } from "../../src/app/(dashboard)/dashboard/providers/components/parseProviderImportFile.ts";
// #6836 — Import providers from CSV/JSON file: parser unit tests.
test("parseProviderImportFile (csv) parses a heterogeneous provider list", () => {
const csv = [
"provider,name,apiKey,baseUrl,priority",
"openai,Prod OpenAI,sk-openai-1,,1",
"anthropic,Prod Anthropic,sk-anthropic-1,,2",
"openai-compatible-foo,Custom Endpoint,sk-custom-1,https://foo.example.com,",
].join("\n");
const result = parseProviderImportFile(csv, "csv");
assert.equal(result.errors.length, 0);
assert.equal(result.entries.length, 3);
assert.deepEqual(result.entries[0], {
provider: "openai",
name: "Prod OpenAI",
apiKey: "sk-openai-1",
priority: 1,
});
assert.deepEqual(result.entries[2], {
provider: "openai-compatible-foo",
name: "Custom Endpoint",
apiKey: "sk-custom-1",
baseUrl: "https://foo.example.com",
});
});
test("parseProviderImportFile (csv) skips comments and blank lines, counts them as skipped", () => {
const csv = ["# a comment", "", "openai,Prod,sk-1,,"].join("\n");
const result = parseProviderImportFile(csv, "csv");
assert.equal(result.entries.length, 1);
assert.equal(result.skipped, 2);
});
test("parseProviderImportFile (csv) works without a header row", () => {
const csv = "anthropic,My Key,sk-abc";
const result = parseProviderImportFile(csv, "csv");
assert.equal(result.entries.length, 1);
assert.equal(result.entries[0].provider, "anthropic");
});
test("parseProviderImportFile (csv) rejects a row missing required fields", () => {
const csv = [
"provider,name,apiKey",
",Missing Provider,sk-1",
"openai,,sk-2",
"openai,Missing Key,",
].join("\n");
const result = parseProviderImportFile(csv, "csv");
assert.equal(result.entries.length, 0);
assert.equal(result.errors.length, 3);
assert.equal(result.errors[0].reason, "importErrorMissingProvider");
assert.equal(result.errors[1].reason, "importErrorMissingName");
assert.equal(result.errors[2].reason, "importErrorMissingApiKey");
});
test("parseProviderImportFile (csv) rejects a malformed row with too few columns", () => {
const csv = "provider,name,apiKey\nopenai,OnlyName";
const result = parseProviderImportFile(csv, "csv");
assert.equal(result.entries.length, 0);
assert.equal(result.errors.length, 1);
assert.equal(result.errors[0].reason, "importErrorMalformedRow");
});
test("parseProviderImportFile (csv) rejects an out-of-range priority", () => {
const csv = "openai,Prod,sk-1,,999";
const result = parseProviderImportFile(csv, "csv");
assert.equal(result.entries.length, 0);
assert.equal(result.errors[0].reason, "importErrorInvalidPriority");
});
test("parseProviderImportFile (json) parses a heterogeneous provider list", () => {
const json = JSON.stringify([
{ provider: "openai", name: "Prod OpenAI", apiKey: "sk-openai-1" },
{
provider: "anthropic-compatible-bar",
name: "Custom Anthropic",
apiKey: "sk-bar-1",
baseUrl: "https://bar.example.com",
priority: 5,
},
]);
const result = parseProviderImportFile(json, "json");
assert.equal(result.errors.length, 0);
assert.equal(result.entries.length, 2);
assert.equal(result.entries[1].priority, 5);
assert.equal(result.entries[1].baseUrl, "https://bar.example.com");
});
test("parseProviderImportFile (json) rejects malformed JSON", () => {
const result = parseProviderImportFile("{not valid json", "json");
assert.equal(result.entries.length, 0);
assert.equal(result.errors.length, 1);
assert.equal(result.errors[0].reason, "importErrorMalformedRow");
});
test("parseProviderImportFile (json) rejects a non-array top-level value", () => {
const result = parseProviderImportFile(JSON.stringify({ provider: "openai" }), "json");
assert.equal(result.entries.length, 0);
assert.equal(result.errors[0].reason, "importErrorNotArray");
});
test("parseProviderImportFile (json) reports per-row errors and keeps parsing the rest", () => {
const json = JSON.stringify([
{ provider: "openai", name: "Prod", apiKey: "sk-1" },
{ provider: "", name: "Bad Row", apiKey: "sk-2" },
{ provider: "anthropic", name: "Prod2", apiKey: "sk-3" },
]);
const result = parseProviderImportFile(json, "json");
assert.equal(result.entries.length, 2);
assert.equal(result.errors.length, 1);
assert.equal(result.errors[0].line, 2);
assert.equal(result.errors[0].reason, "importErrorMissingProvider");
});