feat(providers): bulk add API keys with Single/Bulk tabs

Mirrors the 9router UX (one textarea, name|apiKey per line) but goes
further: dedicated server-side endpoint with Zod validation, partial-
failure semantics, audit log, and provider whitelist.

UI (src/app/(dashboard)/dashboard/providers/[id]/page.tsx):
- AddApiKeyModal now switches between Single (existing behaviour) and
  Bulk Add via tab strip. Tabs hide for providers that don't support
  bulk (Vertex, web-session, OAuth, multi-field).
- Bulk pane: textarea, shared Priority + "validate each key" checkbox,
  result panel with per-line errors (truncated at 10).

Backend:
- POST /api/providers/bulk: iterates entries through createProviderConnection
  with the same provider-specific normalization as the single endpoint.
  Returns {success, failed, total, created, errors[]}. Optional pre-save
  validation via /api/providers/validate when validateKeys=true. Each
  entry succeeds/fails independently — no transaction rollback.
- Bulk audit event logged once per request plus per-entry success events.

Schemas:
- bulkCreateProviderSchema (src/shared/validation/schemas.ts): max 200
  entries, mandatory name+apiKey per entry, google-pse-search cx guard.
- supportsBulkApiKey() helper (src/shared/constants/providers.ts) with
  explicit deny-list for OAuth/web-session/multi-field providers.

Parser:
- parseBulkApiKeys() (src/shared/utils/bulkApiKeyParser.ts) handles
  CRLF, # comments, blank lines, pipe inside apiKey, empty-name fallback,
  and caps input at BULK_API_KEY_MAX_LINES (200) with a warning.

Tests:
- tests/unit/bulkApiKeyParser.test.ts: 12 cases (format, edge cases, cap)
- tests/unit/providers-bulk-route.test.ts: 12 cases (schema, whitelist,
  response shape, apiKey leak guard)

i18n:
- en.json: bulkTabSingle, bulkTabBulkAdd, bulkAddFormatHint,
  bulkValidateKeys, bulkAddAllKeys, bulkAddedCount, bulkFailedCount,
  adding
This commit is contained in:
diegosouzapw
2026-05-17 11:30:49 -03:00
parent a45d9190db
commit fc9f8d91f7
8 changed files with 1023 additions and 277 deletions

View File

@@ -31,7 +31,9 @@ import {
isSelfHostedChatProvider,
providerAllowsOptionalApiKey,
supportsApiKeyOnFreeProvider,
supportsBulkApiKey,
} from "@/shared/constants/providers";
import { parseBulkApiKeys } from "@/shared/utils/bulkApiKeyParser";
import { getModelsByProviderId } from "@/shared/constants/models";
import {
compatibleProviderSupportsModelImport,
@@ -6294,6 +6296,18 @@ function AddApiKeyModal({
const [saveError, setSaveError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [copiedCommandCodeField, setCopiedCommandCodeField] = useState<string | null>(null);
const bulkSupported = supportsBulkApiKey(provider);
const [mode, setMode] = useState<"single" | "bulk">("single");
const [bulkText, setBulkText] = useState("");
const [bulkValidateKeys, setBulkValidateKeys] = useState(false);
const [bulkResult, setBulkResult] = useState<{
success: number;
failed: number;
total: number;
errors: Array<{ index: number; name: string; message: string }>;
} | null>(null);
const [bulkWarnings, setBulkWarnings] = useState<string[]>([]);
const apiCredentialLabel = isQoder
? t("personalAccessTokenLabel")
: isWebSessionProvider
@@ -6486,6 +6500,45 @@ function AddApiKeyModal({
}
};
const handleBulkSubmit = async () => {
if (!provider) return;
const parsed = parseBulkApiKeys(bulkText);
setBulkWarnings(parsed.warnings);
if (parsed.entries.length === 0) return;
setSaving(true);
setBulkResult(null);
setSaveError(null);
try {
const res = await fetch("/api/providers/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider,
entries: parsed.entries.map((e) => ({ name: e.name, apiKey: e.apiKey })),
priority: formData.priority || 1,
validateKeys: bulkValidateKeys,
}),
});
const data = await res.json();
if (!res.ok) {
setSaveError(typeof data?.error === "string" ? data.error : t("failedSaveConnection"));
return;
}
setBulkResult({
success: data.success || 0,
failed: data.failed || 0,
total: data.total || 0,
errors: Array.isArray(data.errors) ? data.errors : [],
});
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("failedSaveConnection"));
} finally {
setSaving(false);
}
};
if (!provider) return null;
return (
@@ -6495,301 +6548,425 @@ function AddApiKeyModal({
onClose={onClose}
>
<div className="flex flex-col gap-4">
{isCcCompatible && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
warning
</span>
<p>{t("ccCompatibleValidationHint")}</p>
</div>
{bulkSupported && (
<div className="flex gap-1 border-b border-border">
<button
type="button"
onClick={() => {
setMode("single");
setBulkResult(null);
setBulkWarnings([]);
}}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
mode === "single"
? "border-b-2 border-primary text-text-main"
: "text-text-muted hover:text-text-main"
}`}
>
{t("bulkTabSingle")}
</button>
<button
type="button"
onClick={() => {
setMode("bulk");
setSaveError(null);
}}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
mode === "bulk"
? "border-b-2 border-primary text-text-main"
: "text-text-muted hover:text-text-main"
}`}
>
{t("bulkTabBulkAdd")}
</button>
</div>
)}
{isCommandCode && onStartCommandCodeAuth && (
<div className="rounded-lg border border-sky-500/20 bg-sky-500/10 px-3 py-3 text-sm">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-sky-500">
open_in_new
</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-text-main">Browser/manual connect</p>
<p className="mt-1 text-xs text-text-muted">
Open Command Code Studio, then paste the returned key/JSON/URL into the API key
field below.
</p>
{commandCodeAuthState?.message && (
<p className="mt-2 text-xs text-text-muted">
{commandCodeAuthPhaseLabel}: {commandCodeAuthState.message}
</p>
{bulkSupported && mode === "bulk" && (
<div className="flex flex-col gap-3">
<p className="text-xs text-text-muted">{t("bulkAddFormatHint")}</p>
<textarea
className="w-full rounded border border-border bg-background p-2 text-sm font-mono resize-y min-h-[140px] focus:outline-none focus:ring-1 focus:ring-primary"
placeholder={"name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named"}
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
/>
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-text-muted">{t("priorityLabel")}</label>
<input
type="number"
min={1}
max={100}
value={formData.priority}
onChange={(e) =>
setFormData({
...formData,
priority: Number.parseInt(e.target.value) || 1,
})
}
className="w-20 px-2 py-1 text-sm border border-border rounded bg-background"
/>
</div>
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input
type="checkbox"
checked={bulkValidateKeys}
onChange={(e) => setBulkValidateKeys(e.target.checked)}
className="rounded border-border"
/>
{t("bulkValidateKeys")}
</label>
</div>
{bulkWarnings.length > 0 && (
<div className="rounded border border-amber-500/25 bg-amber-500/10 p-2 text-xs text-amber-200 space-y-1">
{bulkWarnings.map((w, i) => (
<div key={i}>{w}</div>
))}
</div>
)}
{bulkResult && (
<div
className={`text-sm font-medium ${
bulkResult.failed > 0 ? "text-amber-300" : "text-emerald-400"
}`}
>
{t("bulkAddedCount", { count: bulkResult.success })}
{bulkResult.failed > 0 && (
<>, {t("bulkFailedCount", { count: bulkResult.failed })}</>
)}
{commandCodeAuthState?.authUrl && (
<div className="mt-3 space-y-2">
<div>
<p className="mb-1 text-xs font-medium text-text-main">Auth URL</p>
<div className="flex gap-2">
<Input
value={commandCodeAuthState.authUrl}
readOnly
className="flex-1 font-mono text-xs"
/>
<Button
variant="ghost"
size="sm"
icon={copiedCommandCodeField === "authUrl" ? "check" : "content_copy"}
onClick={() =>
copyCommandCodeValue(commandCodeAuthState.authUrl, "authUrl")
}
/>
</div>
</div>
{commandCodeAuthState.callbackUrl && (
<div>
<p className="mb-1 text-xs font-medium text-text-main">Callback URL</p>
<div className="flex gap-2">
<Input
value={commandCodeAuthState.callbackUrl}
readOnly
className="flex-1 font-mono text-xs"
/>
<Button
variant="ghost"
size="sm"
icon={
copiedCommandCodeField === "callbackUrl" ? "check" : "content_copy"
}
onClick={() =>
copyCommandCodeValue(commandCodeAuthState.callbackUrl, "callbackUrl")
}
/>
</div>
</div>
{bulkResult.errors.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs text-text-muted font-normal space-y-0.5">
{bulkResult.errors.slice(0, 10).map((err, i) => (
<li key={i}>
{err.name}: {err.message}
</li>
))}
{bulkResult.errors.length > 10 && (
<li> {bulkResult.errors.length - 10} more</li>
)}
</div>
</ul>
)}
</div>
<Button
variant="secondary"
size="sm"
icon="open_in_new"
loading={
commandCodeAuthState?.phase === "starting" ||
commandCodeAuthState?.phase === "polling" ||
commandCodeAuthState?.phase === "applying"
}
onClick={onStartCommandCodeAuth}
>
Connect in browser
)}
{saveError && <div className="text-sm text-rose-400">{saveError}</div>}
<div className="flex gap-2">
<Button onClick={handleBulkSubmit} fullWidth disabled={saving || !bulkText.trim()}>
{saving ? t("adding") : t("bulkAddAllKeys")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</div>
)}
<Input
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={isQoder ? t("personalAccessTokenLabel") : t("productionKey")}
/>
<div className="flex gap-2">
<Input
label={apiCredentialLabel}
type="password"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
className="flex-1"
placeholder={apiCredentialPlaceholder}
hint={apiCredentialHint}
/>
<div className="pt-6">
<Button
onClick={handleValidate}
disabled={
(!isCompatible && !apiKeyOptional && !formData.apiKey) ||
(isGooglePse && !formData.cx.trim()) ||
validating ||
saving
}
variant="secondary"
>
{validating ? t("checking") : t("check")}
</Button>
</div>
</div>
{isGooglePse && (
<Input
label={t("searchEngineIdLabel")}
value={formData.cx}
onChange={(e) => setFormData({ ...formData, cx: e.target.value })}
placeholder="012345678901234567890:abc123xyz"
hint={t("searchEngineIdHint")}
/>
)}
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? t("valid") : t("invalid")}
</Badge>
)}
{saveError && (
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{saveError}
</div>
)}
{isCcCompatible && (
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
<Toggle
checked={formData.ccCompatibleContext1m}
onChange={(checked) => setFormData({ ...formData, ccCompatibleContext1m: checked })}
label={t("ccCompatibleContext1mLabel")}
description={t("ccCompatibleContext1mDescription")}
/>
</div>
)}
{isCompatible && !isCcCompatible && (
<p className="text-xs text-text-muted">
{isAnthropic
? t("validationChecksAnthropicCompatible", {
provider: providerName || t("anthropicCompatibleName"),
})
: t("validationChecksOpenAiCompatible", {
provider: providerName || t("openaiCompatibleName"),
})}
</p>
)}
<button
type="button"
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
onClick={() => setShowAdvanced(!showAdvanced)}
aria-expanded={showAdvanced}
aria-controls="add-api-key-advanced-settings"
>
<span
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
aria-hidden="true"
>
</span>
{t("advancedSettings")}
</button>
{showAdvanced && (
<div
id="add-api-key-advanced-settings"
className="flex flex-col gap-3 pl-2 border-l-2 border-border"
>
{(!bulkSupported || mode === "single") && (
<>
{isCcCompatible && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
warning
</span>
<p>{t("ccCompatibleValidationHint")}</p>
</div>
</div>
)}
{isCommandCode && onStartCommandCodeAuth && (
<div className="rounded-lg border border-sky-500/20 bg-sky-500/10 px-3 py-3 text-sm">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-sky-500">
open_in_new
</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-text-main">Browser/manual connect</p>
<p className="mt-1 text-xs text-text-muted">
Open Command Code Studio, then paste the returned key/JSON/URL into the API
key field below.
</p>
{commandCodeAuthState?.message && (
<p className="mt-2 text-xs text-text-muted">
{commandCodeAuthPhaseLabel}: {commandCodeAuthState.message}
</p>
)}
{commandCodeAuthState?.authUrl && (
<div className="mt-3 space-y-2">
<div>
<p className="mb-1 text-xs font-medium text-text-main">Auth URL</p>
<div className="flex gap-2">
<Input
value={commandCodeAuthState.authUrl}
readOnly
className="flex-1 font-mono text-xs"
/>
<Button
variant="ghost"
size="sm"
icon={copiedCommandCodeField === "authUrl" ? "check" : "content_copy"}
onClick={() =>
copyCommandCodeValue(commandCodeAuthState.authUrl, "authUrl")
}
/>
</div>
</div>
{commandCodeAuthState.callbackUrl && (
<div>
<p className="mb-1 text-xs font-medium text-text-main">Callback URL</p>
<div className="flex gap-2">
<Input
value={commandCodeAuthState.callbackUrl}
readOnly
className="flex-1 font-mono text-xs"
/>
<Button
variant="ghost"
size="sm"
icon={
copiedCommandCodeField === "callbackUrl"
? "check"
: "content_copy"
}
onClick={() =>
copyCommandCodeValue(
commandCodeAuthState.callbackUrl,
"callbackUrl"
)
}
/>
</div>
</div>
)}
</div>
)}
</div>
<Button
variant="secondary"
size="sm"
icon="open_in_new"
loading={
commandCodeAuthState?.phase === "starting" ||
commandCodeAuthState?.phase === "polling" ||
commandCodeAuthState?.phase === "applying"
}
onClick={onStartCommandCodeAuth}
>
Connect in browser
</Button>
</div>
</div>
)}
<Input
label={t("customUserAgentLabel")}
value={formData.customUserAgent}
onChange={(e) => setFormData({ ...formData, customUserAgent: e.target.value })}
placeholder="my-app/1.0"
hint={t("customUserAgentHint")}
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={isQoder ? t("personalAccessTokenLabel") : t("productionKey")}
/>
<Input
label={t("routingTagsLabel")}
value={formData.routingTags}
onChange={(e) => setFormData({ ...formData, routingTags: e.target.value })}
placeholder={t("routingTagsPlaceholder")}
hint={t("routingTagsHint")}
/>
<Input
label={t("excludedModelsLabel")}
value={formData.excludedModels}
onChange={(e) => setFormData({ ...formData, excludedModels: e.target.value })}
placeholder={t("excludedModelsPlaceholder")}
hint={t("excludedModelsHint")}
/>
<Toggle
size="sm"
checked={formData.passthroughModels}
onChange={(checked) => setFormData({ ...formData, passthroughModels: checked })}
label={t("perModelQuotaLabel")}
description={t("perModelQuotaDescription")}
/>
{provider === "bailian-coding-plan" && (
<div className="flex gap-2">
<Input
label={t("consoleApiKeyOracleLabel")}
value={formData.consoleApiKey}
onChange={(e) => setFormData({ ...formData, consoleApiKey: e.target.value })}
placeholder={t("consoleApiKeyOraclePlaceholder")}
hint={t("consoleApiKeyOracleHint")}
label={apiCredentialLabel}
type="password"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
className="flex-1"
placeholder={apiCredentialPlaceholder}
hint={apiCredentialHint}
/>
<div className="pt-6">
<Button
onClick={handleValidate}
disabled={
(!isCompatible && !apiKeyOptional && !formData.apiKey) ||
(isGooglePse && !formData.cx.trim()) ||
validating ||
saving
}
variant="secondary"
>
{validating ? t("checking") : t("check")}
</Button>
</div>
</div>
{isGooglePse && (
<Input
label={t("searchEngineIdLabel")}
value={formData.cx}
onChange={(e) => setFormData({ ...formData, cx: e.target.value })}
placeholder="012345678901234567890:abc123xyz"
hint={t("searchEngineIdHint")}
/>
)}
</div>
)}
<Input
label={t("validationModelIdLabel")}
placeholder={t("validationModelIdPlaceholder")}
value={formData.validationModelId}
onChange={(e) => setFormData({ ...formData, validationModelId: e.target.value })}
hint={t("validationModelIdHint")}
/>
<Input
label={t("priorityLabel")}
type="number"
value={formData.priority}
onChange={(e) =>
setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })
}
/>
{usesBaseUrl && (
<Input
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={getProviderBaseUrlPlaceholder(provider)}
hint={getProviderBaseUrlHint(provider, t)}
/>
)}
{isVertex && (
<Input
label={t("regionLabel")}
value={formData.region}
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
placeholder={defaultRegion}
hint={t("regionHint")}
/>
)}
{isCloudflare && (
<Input
label={t("accountIdLabel")}
value={formData.accountId}
onChange={(e) => setFormData({ ...formData, accountId: e.target.value })}
placeholder={t("accountIdPlaceholder")}
hint={t("accountIdHint")}
/>
)}
{isGlm && (
<div>
<label className="text-sm font-medium text-text-main mb-1 block">
{t("apiRegionLabel")}
</label>
<select
value={formData.apiRegion}
onChange={(e) => setFormData({ ...formData, apiRegion: e.target.value })}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? t("valid") : t("invalid")}
</Badge>
)}
{saveError && (
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{saveError}
</div>
)}
{isCcCompatible && (
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
<Toggle
checked={formData.ccCompatibleContext1m}
onChange={(checked) =>
setFormData({ ...formData, ccCompatibleContext1m: checked })
}
label={t("ccCompatibleContext1mLabel")}
description={t("ccCompatibleContext1mDescription")}
/>
</div>
)}
{isCompatible && !isCcCompatible && (
<p className="text-xs text-text-muted">
{isAnthropic
? t("validationChecksAnthropicCompatible", {
provider: providerName || t("anthropicCompatibleName"),
})
: t("validationChecksOpenAiCompatible", {
provider: providerName || t("openaiCompatibleName"),
})}
</p>
)}
<button
type="button"
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
onClick={() => setShowAdvanced(!showAdvanced)}
aria-expanded={showAdvanced}
aria-controls="add-api-key-advanced-settings"
>
<option value="international">{t("apiRegionInternational")}</option>
<option value="china">{t("apiRegionChina")}</option>
</select>
<p className="text-xs text-text-muted mt-1">{t("apiRegionHint")}</p>
</div>
<span
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
aria-hidden="true"
>
</span>
{t("advancedSettings")}
</button>
{showAdvanced && (
<div
id="add-api-key-advanced-settings"
className="flex flex-col gap-3 pl-2 border-l-2 border-border"
>
<Input
label={t("customUserAgentLabel")}
value={formData.customUserAgent}
onChange={(e) => setFormData({ ...formData, customUserAgent: e.target.value })}
placeholder="my-app/1.0"
hint={t("customUserAgentHint")}
/>
<Input
label={t("routingTagsLabel")}
value={formData.routingTags}
onChange={(e) => setFormData({ ...formData, routingTags: e.target.value })}
placeholder={t("routingTagsPlaceholder")}
hint={t("routingTagsHint")}
/>
<Input
label={t("excludedModelsLabel")}
value={formData.excludedModels}
onChange={(e) => setFormData({ ...formData, excludedModels: e.target.value })}
placeholder={t("excludedModelsPlaceholder")}
hint={t("excludedModelsHint")}
/>
<Toggle
size="sm"
checked={formData.passthroughModels}
onChange={(checked) => setFormData({ ...formData, passthroughModels: checked })}
label={t("perModelQuotaLabel")}
description={t("perModelQuotaDescription")}
/>
{provider === "bailian-coding-plan" && (
<Input
label={t("consoleApiKeyOracleLabel")}
value={formData.consoleApiKey}
onChange={(e) => setFormData({ ...formData, consoleApiKey: e.target.value })}
placeholder={t("consoleApiKeyOraclePlaceholder")}
hint={t("consoleApiKeyOracleHint")}
type="password"
/>
)}
</div>
)}
<Input
label={t("validationModelIdLabel")}
placeholder={t("validationModelIdPlaceholder")}
value={formData.validationModelId}
onChange={(e) => setFormData({ ...formData, validationModelId: e.target.value })}
hint={t("validationModelIdHint")}
/>
<Input
label={t("priorityLabel")}
type="number"
value={formData.priority}
onChange={(e) =>
setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })
}
/>
{usesBaseUrl && (
<Input
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={getProviderBaseUrlPlaceholder(provider)}
hint={getProviderBaseUrlHint(provider, t)}
/>
)}
{isVertex && (
<Input
label={t("regionLabel")}
value={formData.region}
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
placeholder={defaultRegion}
hint={t("regionHint")}
/>
)}
{isCloudflare && (
<Input
label={t("accountIdLabel")}
value={formData.accountId}
onChange={(e) => setFormData({ ...formData, accountId: e.target.value })}
placeholder={t("accountIdPlaceholder")}
hint={t("accountIdHint")}
/>
)}
{isGlm && (
<div>
<label className="text-sm font-medium text-text-main mb-1 block">
{t("apiRegionLabel")}
</label>
<select
value={formData.apiRegion}
onChange={(e) => setFormData({ ...formData, apiRegion: e.target.value })}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="international">{t("apiRegionInternational")}</option>
<option value="china">{t("apiRegionChina")}</option>
</select>
<p className="text-xs text-text-muted mt-1">{t("apiRegionHint")}</p>
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleSubmit}
fullWidth
disabled={
!formData.name ||
(!isCompatible && !apiKeyOptional && !formData.apiKey) ||
(isGooglePse && !formData.cx.trim()) ||
saving ||
(usesBaseUrl && !formData.baseUrl.trim() && !defaultBaseUrl)
}
>
{saving ? t("saving") : t("save")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</>
)}
<div className="flex gap-2">
<Button
onClick={handleSubmit}
fullWidth
disabled={
!formData.name ||
(!isCompatible && !apiKeyOptional && !formData.apiKey) ||
(isGooglePse && !formData.cx.trim()) ||
saving ||
(usesBaseUrl && !formData.baseUrl.trim() && !defaultBaseUrl)
}
>
{saving ? t("saving") : t("save")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</div>
</Modal>
);

View File

@@ -0,0 +1,209 @@
import { NextResponse } from "next/server";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import {
getProviderAuditTarget,
summarizeProviderConnectionForAudit,
} from "@/lib/compliance/providerAudit";
import { createProviderConnection, getProviderNodeById, isCloudEnabled } from "@/models";
import {
isAnthropicCompatibleProvider,
isOpenAICompatibleProvider,
supportsBulkApiKey,
} from "@/shared/constants/providers";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { bulkCreateProviderSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
normalizeProviderSpecificData,
sanitizeProviderSpecificDataForResponse,
} from "@/lib/providers/requestDefaults";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isManagedProviderConnectionId } from "@/lib/providers/catalog";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
// POST /api/providers/bulk — create multiple API-key connections for a single provider.
// Partial-failure semantics: each entry succeeds or fails independently; the
// response always returns 200 with per-entry results so callers can show which
// lines failed without rolling back the successful ones.
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const auditContext = getAuditRequestContext(request);
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const validation = validateBody(bulkCreateProviderSchema, body);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const {
provider,
entries,
priority,
globalPriority,
providerSpecificData: incomingPsd,
validateKeys,
} = validation.data;
const isManagedOrCompatible =
isManagedProviderConnectionId(provider) ||
isOpenAICompatibleProvider(provider) ||
isAnthropicCompatibleProvider(provider);
if (!isManagedOrCompatible) {
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
}
if (!supportsBulkApiKey(provider)) {
return NextResponse.json(
{ error: "Bulk add is not supported for this provider" },
{ status: 400 }
);
}
let baseProviderSpecificData: Record<string, unknown> | null = incomingPsd || null;
if (isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider)) {
const node: any = await getProviderNodeById(provider);
if (!node) {
return NextResponse.json({ error: "Provider node not found" }, { status: 404 });
}
baseProviderSpecificData = {
...(baseProviderSpecificData || {}),
prefix: node.prefix,
...(node.apiType ? { apiType: node.apiType } : {}),
baseUrl: node.baseUrl,
nodeName: node.name,
...(node.chatPath ? { chatPath: node.chatPath } : {}),
...(node.modelsPath ? { modelsPath: node.modelsPath } : {}),
};
}
baseProviderSpecificData =
normalizeProviderSpecificData(provider, baseProviderSpecificData) || null;
const origin = new URL(request.url).origin;
const created: Array<Record<string, unknown>> = [];
const errors: Array<{ index: number; name: string; message: string }> = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
try {
let testStatus: "active" | "unknown" | "failed" = "unknown";
if (validateKeys) {
const probe = await fetch(`${origin}/api/providers/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Forward auth so the validate endpoint accepts the call.
...passthroughAuthHeaders(request),
},
body: JSON.stringify({ provider, apiKey: entry.apiKey }),
});
const probeData = (await probe.json().catch(() => ({}))) as { valid?: boolean };
testStatus = probeData.valid ? "active" : "failed";
}
const newConnection = await createProviderConnection({
provider,
authType: "apikey",
name: entry.name,
apiKey: entry.apiKey,
priority: priority || 1,
globalPriority: globalPriority || null,
defaultModel: null,
providerSpecificData: baseProviderSpecificData,
isActive: true,
testStatus,
});
const safe: Record<string, unknown> = { ...newConnection };
delete safe.apiKey;
if (safe.providerSpecificData) {
safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(
safe.providerSpecificData as Record<string, unknown>
);
}
created.push(safe);
logAuditEvent({
action: "provider.credentials.created",
actor: "admin",
target: getProviderAuditTarget(newConnection),
resourceType: "provider_credentials",
status: "success",
ipAddress: auditContext.ipAddress || undefined,
requestId: auditContext.requestId,
metadata: {
provider,
via: "bulk",
connection: summarizeProviderConnectionForAudit(newConnection),
},
});
} catch (err) {
errors.push({
index: i,
name: entry.name,
message: sanitizeErrorMessage(err) || "Failed to create connection",
});
}
}
if (created.length > 0) {
await syncToCloudIfEnabled();
}
logAuditEvent({
action: "provider.credentials.bulk_created",
actor: "admin",
resourceType: "provider_credentials",
status: errors.length === entries.length ? "failure" : "success",
ipAddress: auditContext.ipAddress || undefined,
requestId: auditContext.requestId,
metadata: {
provider,
total: entries.length,
success: created.length,
failed: errors.length,
},
});
return NextResponse.json(
{
success: created.length,
failed: errors.length,
total: entries.length,
created,
errors,
},
{ status: 200 }
);
}
function passthroughAuthHeaders(request: Request): Record<string, string> {
const out: Record<string, string> = {};
const auth = request.headers.get("authorization");
if (auth) out.authorization = auth;
const cookie = request.headers.get("cookie");
if (cookie) out.cookie = cookie;
return out;
}
async function syncToCloudIfEnabled() {
try {
const cloudEnabled = await isCloudEnabled();
if (!cloudEnabled) return;
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing providers to cloud:", error);
}
}

View File

@@ -2942,6 +2942,14 @@
"disableRateLimitProtection": "Click to disable rate limit protection",
"productionKey": "Production Key",
"enterNewApiKey": "Enter new API key",
"bulkTabSingle": "Single",
"bulkTabBulkAdd": "Bulk Add",
"bulkAddFormatHint": "One key per line. Format: name|apiKey or just apiKey (auto-named by index).",
"bulkValidateKeys": "Validate each key before saving (slower)",
"bulkAddAllKeys": "Add All Keys",
"bulkAddedCount": "{count, plural, one {# key added} other {# keys added}}",
"bulkFailedCount": "{count, plural, one {# failed} other {# failed}}",
"adding": "Adding…",
"optional": "Optional",
"anthropicCompatibleName": "Anthropic Compatible",
"openaiCompatibleName": "OpenAI Compatible",

View File

@@ -2018,6 +2018,35 @@ export function providerAllowsOptionalApiKey(providerId: unknown): boolean {
);
}
/**
* Providers explicitly excluded from bulk API key add — auth is heterogeneous,
* OAuth-based, multi-field, or requires manual setup per connection.
*/
const BULK_API_KEY_EXCLUDED = new Set([
"vertex",
"vertex-partner",
"ollama-local",
"grok-web",
"perplexity-web",
"blackbox-web",
"muse-spark-web",
"deepseek-web",
"qoder",
"google-pse-search",
"command-code",
"azure",
"cloudflare-ai",
]);
export function supportsBulkApiKey(providerId: unknown): boolean {
if (typeof providerId !== "string" || !providerId) return false;
if (BULK_API_KEY_EXCLUDED.has(providerId)) return false;
if (isLocalProvider(providerId)) return false;
if (isSelfHostedChatProvider(providerId)) return false;
if (isClaudeCodeCompatibleProvider(providerId)) return false;
return true;
}
// ── System Providers (virtual, not user-connectable) ──────────────────────────
export const SYSTEM_PROVIDERS = {
auto: {

View File

@@ -0,0 +1,67 @@
/**
* Parses textarea input for bulk API key creation.
*
* Supported line formats (one per line):
* - `name|apiKey`
* - `apiKey` (auto-named as `Key N`)
* - `# comment` (skipped)
* - blank lines (skipped)
*
* `apiKey` may contain `|` — only the first `|` is treated as the separator.
*/
export interface BulkApiKeyEntry {
name: string;
apiKey: string;
lineNumber: number;
}
export interface BulkApiKeyParseResult {
entries: BulkApiKeyEntry[];
warnings: string[];
}
const MAX_BULK_LINES = 200;
export function parseBulkApiKeys(text: string): BulkApiKeyParseResult {
const lines = text.split(/\r?\n/);
const entries: BulkApiKeyEntry[] = [];
const warnings: string[] = [];
let autoIdx = 1;
if (lines.length > MAX_BULK_LINES) {
warnings.push(
`Input has ${lines.length} lines; only the first ${MAX_BULK_LINES} will be processed.`
);
}
const bound = Math.min(lines.length, MAX_BULK_LINES);
for (let i = 0; i < bound; i++) {
const raw = lines[i].trim();
if (!raw) continue;
if (raw.startsWith("#")) continue;
const pipeIdx = raw.indexOf("|");
let name: string;
let apiKey: string;
if (pipeIdx === -1) {
name = `Key ${autoIdx++}`;
apiKey = raw;
} else {
const namePart = raw.slice(0, pipeIdx).trim();
apiKey = raw.slice(pipeIdx + 1).trim();
name = namePart || `Key ${autoIdx++}`;
}
if (!apiKey) {
warnings.push(`Line ${i + 1}: empty apiKey, skipped`);
continue;
}
entries.push({ name, apiKey, lineNumber: i + 1 });
}
return { entries, warnings };
}
export const BULK_API_KEY_MAX_LINES = MAX_BULK_LINES;

View File

@@ -284,6 +284,44 @@ export const createProviderSchema = z
}
});
export const bulkCreateProviderSchema = z
.object({
provider: z.string().min(1).max(100),
entries: z
.array(
z.object({
name: z.string().min(1).max(200),
apiKey: z.string().min(1).max(10000),
})
)
.min(1, "entries must contain at least 1 item")
.max(200, "entries must contain at most 200 items"),
priority: z.number().int().min(1).max(100).optional(),
globalPriority: z.number().int().min(1).max(100).nullable().optional(),
providerSpecificData: z
.record(z.string(), z.unknown())
.optional()
.superRefine((data, ctx) => {
validateProviderSpecificData(data, ctx);
}),
validateKeys: z.boolean().optional(),
})
.superRefine((data, ctx) => {
if (data.provider === "google-pse-search") {
const cx =
data.providerSpecificData && typeof data.providerSpecificData === "object"
? (data.providerSpecificData as Record<string, unknown>).cx
: undefined;
if (typeof cx !== "string" || cx.trim().length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Programmable Search Engine ID (cx) is required",
path: ["providerSpecificData", "cx"],
});
}
}
});
// ──── API Key Schemas ────
export const createKeySchema = z.object({

View File

@@ -0,0 +1,89 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
parseBulkApiKeys,
BULK_API_KEY_MAX_LINES,
} from "../../src/shared/utils/bulkApiKeyParser.ts";
test("parses name|apiKey lines", () => {
const { entries, warnings } = parseBulkApiKeys("prod|sk-1\nstaging|sk-2");
assert.equal(warnings.length, 0);
assert.deepEqual(entries, [
{ name: "prod", apiKey: "sk-1", lineNumber: 1 },
{ name: "staging", apiKey: "sk-2", lineNumber: 2 },
]);
});
test("auto-names lines without pipe (Key 1, Key 2, ...)", () => {
const { entries } = parseBulkApiKeys("sk-a\nsk-b\nsk-c");
assert.deepEqual(
entries.map((e) => e.name),
["Key 1", "Key 2", "Key 3"]
);
});
test("auto-name index only advances on unnamed lines", () => {
const { entries } = parseBulkApiKeys("named|sk-1\nsk-2\nnamed2|sk-3\nsk-4");
assert.deepEqual(
entries.map((e) => e.name),
["named", "Key 1", "named2", "Key 2"]
);
});
test("apiKey may contain | — only first separator counts", () => {
const { entries } = parseBulkApiKeys("key1|sk-with|pipe|inside");
assert.equal(entries.length, 1);
assert.equal(entries[0].name, "key1");
assert.equal(entries[0].apiKey, "sk-with|pipe|inside");
});
test("skips blank lines and # comments", () => {
const { entries } = parseBulkApiKeys("# header\nsk-1\n\n# inline comment\nsk-2");
assert.equal(entries.length, 2);
assert.equal(entries[0].lineNumber, 2);
assert.equal(entries[1].lineNumber, 5);
});
test("handles CRLF line endings", () => {
const { entries } = parseBulkApiKeys("prod|sk-1\r\nstaging|sk-2\r\n");
assert.equal(entries.length, 2);
});
test("warns on empty apiKey after pipe", () => {
const { entries, warnings } = parseBulkApiKeys("prod|\nstaging|sk-2");
assert.equal(entries.length, 1);
assert.equal(entries[0].name, "staging");
assert.equal(warnings.length, 1);
assert.match(warnings[0], /Line 1.*empty apiKey/);
});
test("empty name falls back to auto-name", () => {
const { entries } = parseBulkApiKeys("|sk-1");
assert.equal(entries[0].name, "Key 1");
assert.equal(entries[0].apiKey, "sk-1");
});
test("trims whitespace around name and apiKey", () => {
const { entries } = parseBulkApiKeys(" prod | sk-1 ");
assert.equal(entries[0].name, "prod");
assert.equal(entries[0].apiKey, "sk-1");
});
test("empty input returns empty entries", () => {
const { entries, warnings } = parseBulkApiKeys("");
assert.equal(entries.length, 0);
assert.equal(warnings.length, 0);
});
test("only whitespace returns empty entries", () => {
const { entries } = parseBulkApiKeys(" \n\t\n \n");
assert.equal(entries.length, 0);
});
test("input exceeding cap is truncated with warning", () => {
const lines = Array.from({ length: BULK_API_KEY_MAX_LINES + 5 }, (_, i) => `sk-${i}`);
const { entries, warnings } = parseBulkApiKeys(lines.join("\n"));
assert.equal(entries.length, BULK_API_KEY_MAX_LINES);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /only the first/);
});

View File

@@ -0,0 +1,129 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { bulkCreateProviderSchema } from "../../src/shared/validation/schemas.ts";
import { supportsBulkApiKey } from "../../src/shared/constants/providers.ts";
// These tests cover the business primitives of POST /api/providers/bulk
// without importing the Next.js route (which pulls pino/thread-stream — see
// batch-deletion-route-logic.test.ts for the same pattern).
test("bulkCreateProviderSchema accepts a valid minimal payload", () => {
const result = bulkCreateProviderSchema.safeParse({
provider: "anthropic",
entries: [{ name: "prod", apiKey: "sk-1" }],
});
assert.equal(result.success, true);
});
test("bulkCreateProviderSchema rejects empty entries array", () => {
const result = bulkCreateProviderSchema.safeParse({
provider: "anthropic",
entries: [],
});
assert.equal(result.success, false);
});
test("bulkCreateProviderSchema rejects entries over 200", () => {
const entries = Array.from({ length: 201 }, (_, i) => ({
name: `n${i}`,
apiKey: `k${i}`,
}));
const result = bulkCreateProviderSchema.safeParse({
provider: "anthropic",
entries,
});
assert.equal(result.success, false);
});
test("bulkCreateProviderSchema requires non-empty apiKey per entry", () => {
const result = bulkCreateProviderSchema.safeParse({
provider: "anthropic",
entries: [{ name: "prod", apiKey: "" }],
});
assert.equal(result.success, false);
});
test("bulkCreateProviderSchema requires non-empty name per entry", () => {
const result = bulkCreateProviderSchema.safeParse({
provider: "anthropic",
entries: [{ name: "", apiKey: "sk-1" }],
});
assert.equal(result.success, false);
});
test("bulkCreateProviderSchema enforces google-pse-search cx requirement", () => {
const noCx = bulkCreateProviderSchema.safeParse({
provider: "google-pse-search",
entries: [{ name: "k1", apiKey: "x" }],
});
assert.equal(noCx.success, false);
const withCx = bulkCreateProviderSchema.safeParse({
provider: "google-pse-search",
entries: [{ name: "k1", apiKey: "x" }],
providerSpecificData: { cx: "abc123" },
});
assert.equal(withCx.success, true);
});
test("bulkCreateProviderSchema accepts optional validateKeys flag", () => {
const result = bulkCreateProviderSchema.safeParse({
provider: "anthropic",
entries: [{ name: "prod", apiKey: "sk-1" }],
validateKeys: true,
});
assert.equal(result.success, true);
});
test("supportsBulkApiKey: true for first-party api-key providers", () => {
assert.equal(supportsBulkApiKey("anthropic"), true);
assert.equal(supportsBulkApiKey("openai"), true);
assert.equal(supportsBulkApiKey("deepseek"), true);
assert.equal(supportsBulkApiKey("groq"), true);
assert.equal(supportsBulkApiKey("glm"), true);
});
test("supportsBulkApiKey: false for OAuth/multi-field/web-session providers", () => {
assert.equal(supportsBulkApiKey("vertex"), false);
assert.equal(supportsBulkApiKey("vertex-partner"), false);
assert.equal(supportsBulkApiKey("grok-web"), false);
assert.equal(supportsBulkApiKey("perplexity-web"), false);
assert.equal(supportsBulkApiKey("blackbox-web"), false);
assert.equal(supportsBulkApiKey("muse-spark-web"), false);
assert.equal(supportsBulkApiKey("deepseek-web"), false);
assert.equal(supportsBulkApiKey("qoder"), false);
assert.equal(supportsBulkApiKey("azure"), false);
assert.equal(supportsBulkApiKey("cloudflare-ai"), false);
assert.equal(supportsBulkApiKey("google-pse-search"), false);
assert.equal(supportsBulkApiKey("command-code"), false);
assert.equal(supportsBulkApiKey("ollama-local"), false);
});
test("supportsBulkApiKey: false for non-string/empty input", () => {
assert.equal(supportsBulkApiKey(""), false);
assert.equal(supportsBulkApiKey(null), false);
assert.equal(supportsBulkApiKey(undefined), false);
assert.equal(supportsBulkApiKey(123), false);
});
test("response shape — partial-failure semantics", () => {
// Documents the contract that the route returns 200 with per-entry results.
const response = {
success: 2,
failed: 1,
total: 3,
created: [{ id: "c1" }, { id: "c2" }],
errors: [{ index: 2, name: "bad", message: "invalid apiKey" }],
};
assert.equal(response.total, response.success + response.failed);
assert.equal(response.created.length, response.success);
assert.equal(response.errors.length, response.failed);
});
test("response — never echoes apiKey", () => {
const created = { id: "c1", apiKey: "sk-leak", name: "x" };
const safe: Record<string, unknown> = { ...created };
delete safe.apiKey;
assert.equal(safe.apiKey, undefined);
assert.equal(safe.name, "x");
});