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:
asynx6
2026-07-16 17:00:37 +07:00
committed by decolua
parent 6acc3bb965
commit de680e789f
7 changed files with 228 additions and 22 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.5.30",
"version": "0.5.31",
"description": "9Router web dashboard",
"private": true,
"scripts": {

View File

@@ -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,

View File

@@ -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={() => {

View 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;
}

View 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");
});
});