feat(providers): surface CSV import row errors and ship a template (#12504)

Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca.

O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.
This commit is contained in:
Bob.Hou
2026-09-03 11:39:03 -04:00
committed by GitHub
parent 52456a1cea
commit c2d2b0ac14
11 changed files with 418 additions and 16 deletions

View File

@@ -0,0 +1 @@
- **feat(providers):** import-from-file modal shows per-row API errors and ships a downloadable CSV template ([#12071](https://github.com/diegosouzapw/OmniRoute/issues/12071))

View File

@@ -122,6 +122,8 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
## 📖 Provider Setup
To bulk-add API-key connections from a CSV or JSON file, use **Dashboard → Providers → Import from file**. Columns are positional (`provider,name,apiKey,baseUrl,priority`); `provider` must already exist as a managed provider or a compatible node. See [Import providers from a CSV or JSON file](../providers/CSV-IMPORT.md).
### 🔐 Subscription Providers
#### Claude Code (Pro/Max)

View File

@@ -0,0 +1,43 @@
---
title: "Import providers from a CSV or JSON file"
---
# Import providers from a CSV or JSON file
Dashboard → Providers → **Import from file** creates API-key connections from a CSV or JSON list. Each row can target a different provider. Partial failure is the contract: valid rows still import when others fail, and the modal lists why the failed rows were rejected.
This import does **not** create new OpenAI/Anthropic-compatible endpoint nodes. Create those first (Dashboard → Providers → Add OpenAI-Compatible, or `omniroute nodes add`), then import rows whose `provider` column is that node's id. A per-row `baseUrl` can still override the node's URL.
## CSV (positional)
Column names are cosmetic. The parser splits each row and destructures by index:
| Index | Field | Required | Notes |
| ----- | ----- | -------- | ----- |
| 0 | `provider` | yes | Existing managed provider id (`openai`, `anthropic`, …) **or** an already-registered OpenAI/Anthropic-compatible **node** id |
| 1 | `name` | yes | Connection display name |
| 2 | `apiKey` | yes | API key |
| 3 | `baseUrl` | no | Per-row URL override |
| 4 | `priority` | no | Integer 1100 |
A first line whose first column is the literal word `provider` (any case) is skipped as a header. Blank lines and `#` comments are skipped.
Download a starter file from the import modal (**Download CSV template**). Example:
```csv
# OmniRoute provider import (positional columns)
provider,name,apiKey,baseUrl,priority
openai,Prod OpenAI,sk-your-openai-key,,1
```
A made-up id such as `openai-compatible-chat-001` is not a node. The API returns `Unknown or unsupported provider` for that row; the modal shows it next to the row name.
## JSON
A JSON array of objects with the same fields (`provider`, `name`, `apiKey`, `baseUrl?`, `priority?`). Unlike CSV, JSON keys are named.
```json
[
{ "provider": "openai", "name": "Prod OpenAI", "apiKey": "sk-your-openai-key", "priority": 1 }
]
```

View File

@@ -8,6 +8,7 @@
"AGENTROUTER",
"ZED-DOCKER",
"CURSOR-DOCKER",
"CURSOR-API-KEY-AND-CLI"
"CURSOR-API-KEY-AND-CLI",
"CSV-IMPORT"
]
}

View File

@@ -4,6 +4,12 @@ import { useTranslations } from "next-intl";
import { Button, Modal } from "@/shared/components";
import type { ParsedProviderImportEntry, ProviderImportParseError } from "./parseProviderImportFile";
import { useImportProvidersFromFile } from "./useImportProvidersFromFile";
import {
downloadProviderImportCsvTemplate,
formatImportErrorLine,
visibleImportErrors,
type ImportResult,
} from "./providerImportFeedback";
interface ImportProvidersFromFileModalProps {
isOpen: boolean;
@@ -122,6 +128,30 @@ function FilePickerRow({ fileInputRef, fileName, onFile, t }: FilePickerRowProps
);
}
function ImportResultPanel({ result, t }: { result: ImportResult; t: Translator }) {
const { shown, extra } = visibleImportErrors(result.errors);
const failed = result.failed > 0 || shown.length > 0;
return (
<div
className={`px-3 py-2 rounded border text-sm ${
failed
? "border-amber-500/30 bg-amber-500/10 text-amber-300"
: "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
}`}
>
{t("importFromFileResult", { success: result.success, failed: result.failed })}
{shown.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs text-text-muted font-normal space-y-0.5">
{shown.map((err, i) => (
<li key={i}>{formatImportErrorLine(err)}</li>
))}
{extra > 0 && <li>{t("importFromFileMoreErrors", { count: extra })}</li>}
</ul>
)}
</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).
@@ -141,18 +171,18 @@ export function ImportProvidersFromFileModal({
<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>
<p className="text-xs text-text-muted">{t("importFromFileSchemaHint")}</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>
)}
{s.result && <ImportResultPanel result={s.result} t={t} />}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button size="sm" variant="ghost" onClick={downloadProviderImportCsvTemplate}>
{t("importFromFileDownloadTemplate")}
</Button>
<Button size="sm" variant="secondary" onClick={() => s.handleClose(onClose)}>
{t("cancel")}
</Button>

View File

@@ -0,0 +1,165 @@
/**
* #12071 — import-modal feedback helpers.
*
* POST /api/providers/import already returns per-row `{index,name,provider,message}`.
* The modal used to keep only success/failed/total and drop `errors` on the floor.
* These helpers stay a pure, dependency-free module so the hook can stay under the
* LOC ratchet and the same formatter can be unit-tested without React.
*/
export type ImportRowError = {
index?: number;
name?: string;
provider?: string;
message: string;
};
export type ImportResult = {
success: number;
failed: number;
total: number;
errors: ImportRowError[];
};
const VISIBLE_ERROR_CAP = 10;
function asFiniteNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function asRowError(value: unknown): ImportRowError | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
if (typeof row.message !== "string" || !row.message.trim()) return null;
return {
...(typeof row.index === "number" && Number.isFinite(row.index) ? { index: row.index } : {}),
...(typeof row.name === "string" && row.name.trim() ? { name: row.name.trim() } : {}),
...(typeof row.provider === "string" && row.provider.trim() ? { provider: row.provider.trim() } : {}),
message: row.message.trim(),
};
}
/** Keep counts plus a sanitized `errors` array. A missing/non-array field becomes []. */
export function normalizeImportResponse(data: unknown): ImportResult {
const body = data && typeof data === "object" ? (data as Record<string, unknown>) : {};
const rawErrors = Array.isArray(body.errors) ? body.errors : [];
return {
success: asFiniteNumber(body.success),
failed: asFiniteNumber(body.failed),
total: asFiniteNumber(body.total),
errors: rawErrors.map(asRowError).filter((row): row is ImportRowError => row !== null),
};
}
export type ImportHttpOutcome = {
result: ImportResult;
shouldRefresh: boolean;
};
function httpFailureResult(status: number, data: unknown, fallback: ImportResult): ImportResult {
if (fallback.errors.length > 0) {
return { ...fallback, success: 0 };
}
const body = data && typeof data === "object" ? (data as Record<string, unknown>) : {};
const detail = typeof body.error === "string" ? body.error.trim() : "";
const message = detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`;
return {
success: 0,
failed: Math.max(1, fallback.failed),
total: Math.max(1, fallback.total),
errors: [{ message }],
};
}
/**
* Map an import HTTP response onto the modal result.
* Non-ok statuses still populate `errors`. Refresh is a boolean so the hook
* can await `onImported` outside this function (a throw there must not
* overwrite a successful import result).
*/
export function applyImportHttpOutcome(
res: { ok: boolean; status: number },
data: unknown
): ImportHttpOutcome {
const normalized = normalizeImportResponse(data);
if (!res.ok) {
return { result: httpFailureResult(res.status, data, normalized), shouldRefresh: false };
}
return { result: normalized, shouldRefresh: normalized.success > 0 };
}
/** Parse the import response body. Non-JSON becomes `{ ok: false, data: { error } }`. */
export async function readImportResponse(res: Response): Promise<{
ok: boolean;
status: number;
data: unknown;
}> {
try {
return { ok: res.ok, status: res.status, data: await res.json() };
} catch {
return { ok: false, status: res.status, data: { error: "Invalid JSON body" } };
}
}
export function networkImportFailure(err: unknown): ImportResult {
return {
success: 0,
failed: 1,
total: 1,
errors: [{ message: err instanceof Error ? err.message : "Import request failed" }],
};
}
/** First 10 rows plus the leftover count — same cap as AddApiKeyModal bulk import. */
export function visibleImportErrors(errors: ImportRowError[]): {
shown: ImportRowError[];
extra: number;
} {
return {
shown: errors.slice(0, VISIBLE_ERROR_CAP),
extra: Math.max(0, errors.length - VISIBLE_ERROR_CAP),
};
}
/** One line for the modal list: name, else provider, else 1-based row index. */
export function formatImportErrorLine(err: ImportRowError): string {
const label =
(typeof err.name === "string" && err.name.trim()) ||
(typeof err.provider === "string" && err.provider.trim()) ||
(typeof err.index === "number" && Number.isFinite(err.index) ? `row ${err.index + 1}` : "row");
return `${label}: ${err.message}`;
}
/**
* Positional CSV sample. Column 0 must be an *existing* managed provider id
* or an already-registered OpenAI/Anthropic-compatible node id — this import
* does not create new endpoint nodes. Header names are cosmetic; the parser
* destructures by index (`provider,name,apiKey,baseUrl,priority`).
*/
export const PROVIDER_IMPORT_CSV_TEMPLATE = `# OmniRoute provider import (positional columns)
# Columns: provider, name, apiKey, baseUrl (optional), priority (optional, 1-100)
# The provider column must be an existing managed provider id (openai, anthropic, …)
# or an already-registered OpenAI/Anthropic-compatible node id.
# This import does not create new endpoint nodes. Add those first (Dashboard → Providers → Add OpenAI-Compatible).
provider,name,apiKey,baseUrl,priority
openai,Prod OpenAI,sk-your-openai-key,,1
`;
export function downloadTextFile(content: string, filename: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
try {
document.body.appendChild(link);
link.click();
} finally {
link.remove();
URL.revokeObjectURL(url);
}
}
export function downloadProviderImportCsvTemplate(): void {
downloadTextFile(PROVIDER_IMPORT_CSV_TEMPLATE, "omniroute-provider-import-template.csv", "text/csv");
}

View File

@@ -4,13 +4,17 @@ import {
type ParsedProviderImportEntry,
type ProviderImportParseError,
} from "./parseProviderImportFile";
import {
applyImportHttpOutcome,
networkImportFailure,
readImportResponse,
type ImportResult,
} from "./providerImportFeedback";
export type ImportResult = { success: number; failed: number; total: number };
export type { ImportResult };
/**
* 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.
* State + handlers for ImportProvidersFromFileModal (#6836/#12071).
*/
export function useImportProvidersFromFile(onImported: () => Promise<void>) {
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -66,15 +70,19 @@ export function useImportProvidersFromFile(onImported: () => Promise<void>) {
setImporting(true);
try {
const res = await fetch("/api/providers/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
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();
const parsed = await readImportResponse(res);
const outcome = applyImportHttpOutcome(parsed, parsed.data);
setResult(outcome.result);
if (outcome.shouldRefresh) {
try {
await onImported();
} catch { /* refresh failure must not replace the import result */ }
}
} catch (err) {
setResult(networkImportFailure(err));
} finally {
setImporting(false);
}

View File

@@ -5067,6 +5067,9 @@
"importFromFileImporting": "Importing…",
"importFromFileImport": "Import {count} providers",
"importFromFileResult": "Imported {success} providers ({failed} failed)",
"importFromFileDownloadTemplate": "Download CSV template",
"importFromFileMoreErrors": "+{count} more",
"importFromFileSchemaHint": "CSV columns are positional: provider, name, apiKey, baseUrl (optional), priority (optional). The provider column must be an existing managed provider id or an already-registered OpenAI/Anthropic-compatible node id — this import does not create new endpoint nodes.",
"adaptaTutorial": {
"title": "How to connect Adapta Web",
"introPrefix": "Adapta authenticates through Clerk. The token",

View File

@@ -5068,6 +5068,9 @@
"importFromFileImporting": "Importando…",
"importFromFileImport": "Importar {count} provedores",
"importFromFileResult": "{success} provedores importados ({failed} falharam)",
"importFromFileDownloadTemplate": "Baixar modelo CSV",
"importFromFileMoreErrors": "+{count} mais",
"importFromFileSchemaHint": "As colunas CSV são posicionais: provider, name, apiKey, baseUrl (opcional), priority (opcional). A coluna provider deve ser o id de um provedor gerenciado existente ou o id de um nó compatível com OpenAI/Anthropic já registrado — esta importação não cria novos nós de endpoint.",
"adaptaTutorial": {
"title": "Como conectar o Adapta Web",
"introPrefix": "Adapta autentica através do Clerk. O token",

View File

@@ -5068,6 +5068,9 @@
"importFromFileImporting": "Đang nhập…",
"importFromFileImport": "Nhập {count} nhà cung cấp",
"importFromFileResult": "Đã nhập {success} nhà cung cấp ({failed} không thành công)",
"importFromFileDownloadTemplate": "Tải mẫu CSV",
"importFromFileMoreErrors": "+{count} nữa",
"importFromFileSchemaHint": "Các cột CSV theo vị trí: provider, name, apiKey, baseUrl (tùy chọn), priority (tùy chọn). Cột provider phải là id nhà cung cấp được quản lý hiện có hoặc id nút tương thích OpenAI/Anthropic đã đăng ký — quá trình nhập này không tạo nút endpoint mới.",
"adaptaTutorial": {
"title": "How to connect Adapta Web",
"introPrefix": "Adapta authenticates through Clerk. The token",

View File

@@ -0,0 +1,143 @@
import test from "node:test";
import assert from "node:assert/strict";
const feedback = await import(
"../../src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts"
);
const { parseProviderImportFile } = await import(
"../../src/app/(dashboard)/dashboard/providers/components/parseProviderImportFile.ts"
);
test("#12071 normalizeImportResponse keeps the per-row errors array", () => {
const result = feedback.normalizeImportResponse({
success: 1,
failed: 2,
total: 3,
errors: [
{ index: 1, name: "srv-107", provider: "openai-compatible-chat-001", message: "Unknown or unsupported provider" },
{ index: 2, name: "srv-135", provider: "openai", message: "Provider node not found" },
],
});
assert.equal(result.success, 1);
assert.equal(result.failed, 2);
assert.equal(result.total, 3);
assert.equal(result.errors.length, 2);
assert.equal(result.errors[0].message, "Unknown or unsupported provider");
assert.equal(result.errors[1].name, "srv-135");
});
test("#12071 normalizeImportResponse treats a missing errors field as [] (today's silent drop)", () => {
const result = feedback.normalizeImportResponse({ success: 0, failed: 3, total: 3 });
assert.deepEqual(result.errors, []);
assert.equal(result.failed, 3);
});
test("#12071 normalizeImportResponse ignores a non-array errors field", () => {
const result = feedback.normalizeImportResponse({ success: 0, failed: 1, total: 1, errors: "boom" });
assert.deepEqual(result.errors, []);
});
test("#12071 visibleImportErrors caps at 10 and reports the remainder", () => {
const errors = Array.from({ length: 12 }, (_, i) => ({ message: `row ${i}` }));
const { shown, extra } = feedback.visibleImportErrors(errors);
assert.equal(shown.length, 10);
assert.equal(extra, 2);
assert.equal(shown[0].message, "row 0");
});
test("#12071 formatImportErrorLine prefers name, then provider, then 1-based row", () => {
assert.equal(
feedback.formatImportErrorLine({ name: "Grade-S-Node", message: "Unknown or unsupported provider" }),
"Grade-S-Node: Unknown or unsupported provider"
);
assert.equal(
feedback.formatImportErrorLine({ provider: "openai", message: "Provider node not found" }),
"openai: Provider node not found"
);
assert.equal(feedback.formatImportErrorLine({ index: 0, message: "failed" }), "row 1: failed");
});
test("#12071 CSV template is positional and parses to one openai row", () => {
const parsed = parseProviderImportFile(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, "csv");
assert.equal(parsed.errors.length, 0);
assert.equal(parsed.entries.length, 1);
assert.equal(parsed.entries[0].provider, "openai");
assert.equal(parsed.entries[0].name, "Prod OpenAI");
assert.equal(parsed.entries[0].apiKey, "sk-your-openai-key");
assert.equal(parsed.entries[0].priority, 1);
});
test("#12071 CSV template comments document that provider must already exist", () => {
assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /existing managed provider/i);
assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /does not create new endpoint nodes/i);
assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /positional/i);
});
test("#12071 asRowError trims leading/trailing whitespace on message", () => {
const result = feedback.normalizeImportResponse({
success: 0,
failed: 1,
total: 1,
errors: [{ name: "srv-107", message: " Unknown or unsupported provider " }],
});
assert.equal(result.errors.length, 1);
assert.equal(result.errors[0].message, "Unknown or unsupported provider");
});
test("#12071 applyImportHttpOutcome on !ok zeros success even if the body claimed some", () => {
const outcome = feedback.applyImportHttpOutcome(
{ ok: false, status: 500 },
{
success: 5,
failed: 0,
total: 5,
errors: [{ name: "a", message: "Unknown or unsupported provider" }],
}
);
assert.equal(outcome.shouldRefresh, false);
assert.equal(outcome.result.success, 0);
assert.equal(outcome.result.errors.length, 1);
});
test("#12071 applyImportHttpOutcome surfaces non-ok HTTP without calling onImported", () => {
const outcome = feedback.applyImportHttpOutcome(
{ ok: false, status: 400 },
{ error: "Invalid JSON body" }
);
assert.equal(outcome.shouldRefresh, false);
assert.equal(outcome.result.success, 0);
assert.equal(outcome.result.failed, 1);
assert.equal(outcome.result.errors.length, 1);
assert.match(outcome.result.errors[0].message, /HTTP 400/);
});
test("#12071 applyImportHttpOutcome on ok with success>0 requests refresh", () => {
const outcome = feedback.applyImportHttpOutcome(
{ ok: true, status: 200 },
{ success: 2, failed: 1, total: 3, errors: [{ name: "bad", message: "Unknown or unsupported provider" }] }
);
assert.equal(outcome.shouldRefresh, true);
assert.equal(outcome.result.success, 2);
assert.equal(outcome.result.failed, 1);
assert.equal(outcome.result.errors[0].name, "bad");
});
test("#12071 applyImportHttpOutcome on ok with success=0 still keeps errors and skips refresh", () => {
const outcome = feedback.applyImportHttpOutcome(
{ ok: true, status: 200 },
{ success: 0, failed: 3, total: 3, errors: [{ name: "a", message: "Unknown or unsupported provider" }] }
);
assert.equal(outcome.shouldRefresh, false);
assert.equal(outcome.result.success, 0);
assert.equal(outcome.result.errors.length, 1);
});
test("#12071 readImportResponse treats JSON parse failure as !ok with a body error", async () => {
const res = new Response("not-json", { status: 200, headers: { "Content-Type": "text/plain" } });
const parsed = await feedback.readImportResponse(res);
assert.equal(parsed.ok, false);
assert.equal(parsed.status, 200);
const outcome = feedback.applyImportHttpOutcome(parsed, parsed.data);
assert.equal(outcome.shouldRefresh, false);
assert.match(outcome.result.errors[0].message, /Invalid JSON body/);
});