fix(providers): bulk-add API keys no longer overwrite existing keys
Bulk-add named auto-generated keys by paste-line index, blind to existing connection names. The backend upserts apikey connections by exact name (connectionsRepo), so a colliding generated name silently replaced an existing key instead of inserting a new one. Add a collision-aware planner (src/shared/utils/bulkAdd.js) that gap-fills the smallest free "<base> <n>" against both existing connection names and names assigned earlier in the same batch, so a generated name is never reused and the backend always inserts. Applies to auto-named lines, custom name|apiKey lines, and Cloudflare name|apiKey|accountId lines. Wire the planner into AddApiKeyModal and pass existing connection names from the provider detail page. Add unit tests covering gap-fill, custom names, Cloudflare 3-part format, and robustness.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.5.30",
|
||||
"version": "0.5.31",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
94
src/shared/utils/bulkAdd.js
Normal file
94
src/shared/utils/bulkAdd.js
Normal file
@@ -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 "<base> <lineIndex>" from the paste position, blind to existing
|
||||
// names, so re-adding keys often silently replaced earlier ones.
|
||||
//
|
||||
// This planner gap-fills the smallest free "<base> <n>" 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 " <n>", 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 "<base> <n>" 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 "<base> <n>" 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;
|
||||
}
|
||||
113
tests/unit/bulk-add-names.test.js
Normal file
113
tests/unit/bulk-add-names.test.js
Normal file
@@ -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 "<base> <n>" 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user