mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 20:52:15 +03:00
feat(dashboard): import providers from CSV/JSON file (#6836)
This commit is contained in:
1
changelog.d/features/6836-import-providers-from-file.md
Normal file
1
changelog.d/features/6836-import-providers-from-file.md
Normal 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)
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
212
src/app/api/providers/import/route.ts
Normal file
212
src/app/api/providers/import/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
122
tests/unit/api/providers-import-route-6836.test.ts
Normal file
122
tests/unit/api/providers-import-route-6836.test.ts
Normal 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");
|
||||
});
|
||||
120
tests/unit/parse-provider-import-6836.test.ts
Normal file
120
tests/unit/parse-provider-import-6836.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user