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:
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user