diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5cdd2373..12c1dd6c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+# v0.5.31 (2026-07-13)
+
+## Fixes
+- **Providers**: bulk-add API keys no longer overwrite existing keys. Auto-generated `Key N` names are now gap-filled against existing connection names (and earlier entries in the same batch), so a generated name never collides with a saved one. Previously the bulk-add modal named keys by paste-line index, blind to existing names, and the backend upserts apikey connections by name — so a colliding generated name silently replaced an existing key instead of inserting a new one. Custom `name|apiKey` lines and Cloudflare `name|apiKey|accountId` lines get the same collision-free numbering.
+
# v0.5.30 (2026-07-10)
## Features
diff --git a/cli/package.json b/cli/package.json
index e72fd187..ac2196fd 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "9router",
- "version": "0.5.30",
+ "version": "0.5.31",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"
diff --git a/package.json b/package.json
index 352b661f..9f714c29 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "9router-app",
- "version": "0.5.30",
+ "version": "0.5.31",
"description": "9Router web dashboard",
"private": true,
"scripts": {
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
index 0a272045..5d19bd10 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
@@ -4,10 +4,11 @@ import { useState } from "react";
import PropTypes from "prop-types";
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
+import { planBulkAdd } from "@/shared/utils/bulkAdd";
const BULK_PLACEHOLDER = `name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named`;
-export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, onSave, onBulkDone, onClose }) {
+export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, existingNames, onSave, onBulkDone, onClose }) {
const NONE_PROXY_POOL_VALUE = "__none__";
const isOllamaLocal = provider === "ollama-local";
const isCookie = authType === "cookie";
@@ -131,38 +132,29 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
};
const handleBulkSubmit = async () => {
- const lines = bulkText.split("\n").map(l => l.trim()).filter(Boolean);
+ const lines = bulkText.split("\n");
if (!lines.length) return;
+ // Plan collision-free names against existing connections so a generated
+ // "Key N" never matches a saved name (which the backend would upsert /
+ // overwrite instead of inserting). See bulkAdd.js for the full rationale.
+ const plan = planBulkAdd(lines, existingNames, { isCloudflareAi });
+ if (!plan.length) return;
setSaving(true);
setBulkResult(null);
let success = 0;
let failed = 0;
- for (let i = 0; i < lines.length; i++) {
- const parts = lines[i].split("|");
- const baseName = parts.length >= 2 ? parts[0].trim() : "Key";
- const name = `${baseName} ${i + 1}`;
-
- let apiKey;
- let providerSpecificData;
- if (isCloudflareAi && parts.length >= 3) {
- // Format: name|apiKey|accountId
- apiKey = parts.slice(1, -1).join("|").trim();
- providerSpecificData = { accountId: parts[parts.length - 1].trim() };
- } else {
- apiKey = parts.length >= 2 ? parts.slice(1).join("|").trim() : parts[0].trim();
- }
-
+ for (const entry of plan) {
try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider,
- apiKey,
- name,
+ apiKey: entry.apiKey,
+ name: entry.name,
priority: 1,
testStatus: "unknown",
- ...(providerSpecificData ? { providerSpecificData } : {}),
+ ...(entry.providerSpecificData ? { providerSpecificData: entry.providerSpecificData } : {}),
}),
});
if (res.ok) success++;
@@ -409,6 +401,7 @@ AddApiKeyModal.propTypes = {
name: PropTypes.string,
})),
error: PropTypes.string,
+ existingNames: PropTypes.arrayOf(PropTypes.string),
onSave: PropTypes.func.isRequired,
onBulkDone: PropTypes.func,
onClose: PropTypes.func.isRequired,
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js
index 845eed85..dff51fe0 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/page.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js
@@ -1690,6 +1690,7 @@ export default function ProviderDetailPage() {
website={providerInfo?.website}
proxyPools={proxyPools}
error={addConnectionError}
+ existingNames={connections.map((c) => c.name).filter(Boolean)}
onSave={handleSaveApiKey}
onBulkDone={fetchConnections}
onClose={() => {
diff --git a/src/shared/utils/bulkAdd.js b/src/shared/utils/bulkAdd.js
new file mode 100644
index 00000000..78f54264
--- /dev/null
+++ b/src/shared/utils/bulkAdd.js
@@ -0,0 +1,94 @@
+// Bulk-add API-key planner.
+//
+// Background: the backend upserts apikey connections BY NAME
+// (src/lib/db/repos/connectionsRepo.js ~L144: existing = all.find(c =>
+// c.authType === "apikey" && c.name === data.name)). A colliding name
+// overwrites an existing key instead of inserting a new one. Bulk-add used to
+// derive " " from the paste position, blind to existing
+// names, so re-adding keys often silently replaced earlier ones.
+//
+// This planner gap-fills the smallest free " " against both existing
+// connection names and names already assigned earlier in the same batch, so a
+// generated name is never reused and the backend always inserts.
+//
+// ponytail: only numeric-suffix collision is handled. A user who manually
+// types an exact existing non-numbered custom name (no index) will still hit
+// the backend upsert — but bulk auto-naming always appends " ", so this
+// path is unreachable from the bulk modal. Upgrade path: a backend
+// "skip-if-exists" flag on POST /api/providers if single-add ever needs it.
+
+/**
+ * Parse one pipe-separated bulk line into { baseName, apiKey, providerSpecificData? }.
+ * @param {string} line
+ * @param {{isCloudflareAi?: boolean}} [opts]
+ * @returns {{baseName: string, apiKey: string, providerSpecificData?: object}|null}
+ */
+function parseLine(line, opts = {}) {
+ const { isCloudflareAi = false } = opts;
+ const parts = line.split("|");
+
+ if (isCloudflareAi && parts.length >= 3) {
+ // name|apiKey|accountId (apiKey may itself contain pipes)
+ const baseName = parts[0].trim();
+ const apiKey = parts.slice(1, -1).join("|").trim();
+ const accountId = parts[parts.length - 1].trim();
+ return {
+ baseName: baseName || "Key",
+ apiKey,
+ providerSpecificData: { accountId },
+ };
+ }
+
+ if (parts.length >= 2) {
+ // name|apiKey (apiKey may itself contain pipes)
+ const baseName = parts[0].trim();
+ const apiKey = parts.slice(1).join("|").trim();
+ return { baseName: baseName || "Key", apiKey };
+ }
+
+ // apiKey only — auto-named "Key N"
+ const apiKey = parts[0].trim();
+ return { baseName: "Key", apiKey };
+}
+
+/**
+ * Plan a bulk add: parse lines, assign collision-free " " names.
+ *
+ * @param {string[]} lines raw paste lines
+ * @param {string[]|null|undefined} existingNames connection names already saved
+ * @param {{isCloudflareAi?: boolean}} [opts]
+ * @returns {{name: string, apiKey: string, skipped: boolean, providerSpecificData?: object}[]}
+ */
+export function planBulkAdd(lines, existingNames, opts = {}) {
+ const { isCloudflareAi = false } = opts;
+
+ const safeExisting = Array.isArray(existingNames) ? existingNames : [];
+ const used = new Set(safeExisting.map((n) => (typeof n === "string" ? n.toLowerCase() : "")));
+
+ const out = [];
+ for (const raw of lines) {
+ const line = typeof raw === "string" ? raw.trim() : "";
+ if (!line) continue;
+
+ const parsed = parseLine(line, { isCloudflareAi });
+ if (!parsed || !parsed.apiKey) continue;
+
+ const base = parsed.baseName;
+
+ // Gap-fill from 1: smallest free " " not in `used`.
+ // O(batch * existing) — fine for bulk add (tens to low hundreds of keys).
+ let idx = 1;
+ let name;
+ for (;;) {
+ name = `${base} ${idx}`;
+ if (!used.has(name.toLowerCase())) break;
+ idx += 1;
+ }
+ used.add(name.toLowerCase());
+
+ const entry = { name, apiKey: parsed.apiKey, skipped: false };
+ if (parsed.providerSpecificData) entry.providerSpecificData = parsed.providerSpecificData;
+ out.push(entry);
+ }
+ return out;
+}
diff --git a/tests/unit/bulk-add-names.test.js b/tests/unit/bulk-add-names.test.js
new file mode 100644
index 00000000..cd012dba
--- /dev/null
+++ b/tests/unit/bulk-add-names.test.js
@@ -0,0 +1,113 @@
+// Guards the bulk-add API-key naming bug: auto-generated "Key N" names used to be
+// derived from the paste-line index, blind to existing connection names. The
+// backend upserts apikey connections by name (connectionsRepo), so a colliding
+// generated name OVERWROTE an existing key instead of adding a new one.
+// Fix: planBulkAdd gap-fills the smallest free " " against existing
+// names (and earlier entries in the same batch) so a name is never reused.
+import { describe, it, expect } from "vitest";
+import { planBulkAdd } from "../../src/shared/utils/bulkAdd.js";
+
+describe("planBulkAdd: auto-named gap-fill (the replace bug)", () => {
+ it("uses Key 1..N by paste index when nothing exists", () => {
+ const out = planBulkAdd(["sk-a", "sk-b", "sk-c"], []);
+ expect(out.map(o => o.name)).toEqual(["Key 1", "Key 2", "Key 3"]);
+ expect(out.every(o => o.skipped === false)).toBe(true);
+ });
+
+ it("gap-fills around existing names — never reuses an existing name", () => {
+ // Key 3 and Key 5 already exist; user adds 4 keys.
+ // Free slots: 1, 2, 4, 6 -> assign those, never 3 or 5.
+ const out = planBulkAdd(["sk-a", "sk-b", "sk-c", "sk-d"], ["Key 3", "Key 5"]);
+ expect(out.map(o => o.name)).toEqual(["Key 1", "Key 2", "Key 4", "Key 6"]);
+ });
+
+ it("continues past the highest existing index when low slots are taken", () => {
+ const out = planBulkAdd(["sk-a", "sk-b"], ["Key 1", "Key 2"]);
+ expect(out.map(o => o.name)).toEqual(["Key 3", "Key 4"]);
+ });
+
+ it("skips blank/whitespace-only lines but keeps indexing contiguous", () => {
+ const out = planBulkAdd(["sk-a", " ", "", "sk-b"], []);
+ expect(out.map(o => o.name)).toEqual(["Key 1", "Key 2"]);
+ expect(out.map(o => o.apiKey)).toEqual(["sk-a", "sk-b"]);
+ });
+
+ it("within-batch names are unique even for the same free slot", () => {
+ const out = planBulkAdd(["sk-a", "sk-b", "sk-c"], ["Key 1"]);
+ // Key 1 taken; batch gets 2, 3, 4 — no internal dup.
+ const names = out.map(o => o.name);
+ expect(new Set(names).size).toBe(names.length);
+ expect(names).toEqual(["Key 2", "Key 3", "Key 4"]);
+ });
+});
+
+describe("planBulkAdd: custom name|apiKey", () => {
+ it("uses the literal base name with a gap-filled index", () => {
+ const out = planBulkAdd(["Prod|sk-1", "Prod|sk-2"], []);
+ expect(out.map(o => o.name)).toEqual(["Prod 1", "Prod 2"]);
+ expect(out.map(o => o.apiKey)).toEqual(["sk-1", "sk-2"]);
+ });
+
+ it("custom name avoids an existing same-base name", () => {
+ // "Prod 1" exists -> first new "Prod|.." line becomes "Prod 2".
+ const out = planBulkAdd(["Prod|sk-new"], ["Prod 1"]);
+ expect(out[0].name).toBe("Prod 2");
+ });
+
+ it("apiKey containing pipes is preserved (parts after first rejoined)", () => {
+ const out = planBulkAdd(["Prod|sk|with|pipes"], []);
+ expect(out[0].apiKey).toBe("sk|with|pipes");
+ expect(out[0].name).toBe("Prod 1");
+ });
+});
+
+describe("planBulkAdd: cloudflare-ai (name|apiKey|accountId)", () => {
+ it("parses 3-part lines into name + apiKey + accountId", () => {
+ const out = planBulkAdd(
+ ["main|sk-key1|acc123", "main|sk-key2|def789"],
+ [],
+ { isCloudflareAi: true }
+ );
+ expect(out.map(o => o.name)).toEqual(["main 1", "main 2"]);
+ expect(out[0].apiKey).toBe("sk-key1");
+ expect(out[0].providerSpecificData).toEqual({ accountId: "acc123" });
+ expect(out[1].providerSpecificData).toEqual({ accountId: "def789" });
+ });
+
+ it("2-part cloudflare line is name|apiKey (no accountId)", () => {
+ const out = planBulkAdd(["main|sk-key1"], [], { isCloudflareAi: true });
+ expect(out[0].name).toBe("main 1");
+ expect(out[0].apiKey).toBe("sk-key1");
+ expect(out[0].providerSpecificData).toBeUndefined();
+ });
+
+ it("1-part cloudflare line is auto-named Key N", () => {
+ const out = planBulkAdd(["sk-key1"], [], { isCloudflareAi: true });
+ expect(out[0].name).toBe("Key 1");
+ expect(out[0].apiKey).toBe("sk-key1");
+ });
+});
+
+describe("planBulkAdd: robustness", () => {
+ it("returns [] for no input", () => {
+ expect(planBulkAdd([], [])).toEqual([]);
+ expect(planBulkAdd(["", " "], [])).toEqual([]);
+ });
+
+ it("trims names and apiKeys", () => {
+ const out = planBulkAdd([" Prod | sk-1 "], []);
+ expect(out[0].name).toBe("Prod 1");
+ expect(out[0].apiKey).toBe("sk-1");
+ });
+
+ it("falls back to base 'Key' when name part is empty", () => {
+ const out = planBulkAdd(["|sk-1"], []);
+ expect(out[0].name).toBe("Key 1");
+ expect(out[0].apiKey).toBe("sk-1");
+ });
+
+ it("coerces non-array existingNames gracefully", () => {
+ const out = planBulkAdd(["sk-a"], null);
+ expect(out[0].name).toBe("Key 1");
+ });
+});