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