feat(providers): bulk-add API keys for Cloudflare Workers AI (#6174) (#6254)

feat(providers): bulk-add API keys for Cloudflare Workers AI (#6174). Per-entry providerSpecificData (fixes shared-object reuse); TDD guard bulk-api-key-parser-cloudflare.test.ts. Base-reds only. Integrated into release/v3.8.45. (thanks @muflifadla38)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-05 07:42:29 -03:00
committed by GitHub
parent 5531fc7f05
commit 776a7a3a58
9 changed files with 270 additions and 10 deletions

View File

@@ -6,6 +6,7 @@
- **feat(providers):** add **Yuanbao (web)** as a cookie-session provider ([#6196](https://github.com/diegosouzapw/OmniRoute/issues/6196)) — `yuanbao-web` (Tencent Yuanbao, `yuanbao.tencent.com`) with cookie-only auth (`hy_user`/`hy_token` + public agent id), SSE→OpenAI translation incl. `reasoning_content`, exposing DeepSeek V3/R1 + Hunyuan / Hunyuan-T1. Regression guard: `tests/unit/providers-yuanbao-web.test.ts`. `together-web` was **deferred** (no verifiable web-session endpoint — needs a captured request) and `huggingchat-web` **dropped** (the existing `huggingchat` already is a web-cookie provider). (thanks @chirag127)
- **feat(providers):** route the built-in **agentrouter** through the dynamic Claude-Code wire image ([#6056](https://github.com/diegosouzapw/OmniRoute/issues/6056)) — a small static allow-set (`CC_WIRE_IMAGE_BUILTINS` in `open-sse/services/ccWireImageBuiltins.ts`), consulted by `isClaudeCodeCompatible` / `isClaudeCodeCompatibleProvider` / `applyFingerprint`, makes agentrouter adopt the CC wire-image headers + fingerprint **while guarding the CC baseUrl/auth branches** so it keeps its own registry `baseUrl` and `x-api-key` auth. Regression guard: `tests/unit/agentrouter-cc-wire-image.test.ts` (asserts the wire image is applied AND agentrouter's baseUrl/auth are preserved). Live WAF-acceptance against agentrouter.org is a VPS validation follow-up (Hard Rule #18).
- **feat(providers):** **bulk-add API keys for Cloudflare Workers AI** ([#6174](https://github.com/diegosouzapw/OmniRoute/issues/6174)) — `cloudflare-ai` is removed from the bulk-add exclusion list and the bulk parser gains a 3-field `name|accountId|apiKey` mode; the bulk route now builds a **per-entry** `providerSpecificData` so each key carries its own `accountId` (fixing the previous shared-object reuse), and both the create + key-validation paths receive it. Regression guard: `tests/unit/bulk-api-key-parser-cloudflare.test.ts`. (thanks @muflifadla38)
### 🐛 Bug Fixes

View File

@@ -348,7 +348,7 @@ export default function AddApiKeyModal({
const handleBulkSubmit = async () => {
if (!provider) return;
const parsed = parseBulkApiKeys(bulkText);
const parsed = parseBulkApiKeys(bulkText, { withAccountId: isCloudflare });
setBulkWarnings(parsed.warnings);
if (parsed.entries.length === 0) return;
@@ -378,7 +378,11 @@ export default function AddApiKeyModal({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider,
entries: parsed.entries.map((e) => ({ name: e.name, apiKey: e.apiKey })),
entries: parsed.entries.map((e) => ({
name: e.name,
apiKey: e.apiKey,
...(e.accountId ? { accountId: e.accountId } : {}),
})),
priority: formData.priority || 1,
providerSpecificData,
validateKeys: bulkValidateKeys,
@@ -457,12 +461,18 @@ export default function AddApiKeyModal({
{bulkSupported && mode === "bulk" && (
<div className="flex flex-col gap-3">
<p className="text-xs text-text-muted">{t("bulkAddFormatHint")}</p>
<p className="text-xs text-text-muted">
{isCloudflare ? t("bulkAddFormatHintCloudflare") : t("bulkAddFormatHint")}
</p>
{openRouterPreset.input}
{freeModelsToggle}
<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"}
placeholder={
isCloudflare
? "name1|account-id-1|cf-token-1\nname2|account-id-2|cf-token-2"
: "name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named"
}
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
/>

View File

@@ -109,6 +109,15 @@ export async function POST(request: Request) {
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
try {
// Per-entry copy so each connection gets its own providerSpecificData. Cloudflare
// Workers AI carries a per-key accountId (name|accountId|apiKey) that must NOT bleed
// across entries — never mutate/reuse the shared base object here.
const entryProviderSpecificData: Record<string, unknown> = {
...(baseProviderSpecificData || {}),
...(entry.accountId ? { accountId: entry.accountId } : {}),
};
const hasEntryPsd = Object.keys(entryProviderSpecificData).length > 0;
let testStatus: "active" | "unknown" | "failed" = "unknown";
if (validateKeys) {
@@ -116,7 +125,7 @@ export async function POST(request: Request) {
validateProviderApiKey({
provider,
apiKey: entry.apiKey,
providerSpecificData: baseProviderSpecificData || {},
providerSpecificData: entryProviderSpecificData,
})
);
testStatus = probe?.valid ? "active" : "failed";
@@ -130,7 +139,7 @@ export async function POST(request: Request) {
priority: priority || 1,
globalPriority: globalPriority || null,
defaultModel: null,
providerSpecificData: baseProviderSpecificData,
providerSpecificData: hasEntryPsd ? entryProviderSpecificData : baseProviderSpecificData,
isActive: true,
testStatus,
});

View File

@@ -4848,7 +4848,8 @@
"kimiWebLabel": "Kimi Web",
"kimiWebDesc": "Moonshot AI consumer chat via www.kimi.com (international, Connect-RPC API)",
"doubaoWebLabel": "Dola Web",
"doubaoWebDesc": "ByteDance AI chat via dola.com"
"doubaoWebDesc": "ByteDance AI chat via dola.com",
"bulkAddFormatHintCloudflare": "One key per line. Format: name|accountId|apiKey (Cloudflare account ID + API token)."
},
"settings": {
"title": "Settings",

View File

@@ -196,7 +196,6 @@ const BULK_API_KEY_EXCLUDED = new Set([
"google-pse-search",
"command-code",
"azure",
"cloudflare-ai",
]);
export function supportsBulkApiKey(providerId: unknown): boolean {

View File

@@ -8,12 +8,21 @@
* - blank lines (skipped)
*
* `apiKey` may contain `|` — only the first `|` is treated as the separator.
*
* When `withAccountId` is enabled (Cloudflare Workers AI), each line carries a
* per-key account id in a 3-field shape:
* - `name|accountId|apiKey`
* Only the first two `|` are treated as separators, so an `apiKey` containing
* `|` stays intact. Lines missing the `accountId` or `apiKey` field are flagged
* as warnings and skipped.
*/
export interface BulkApiKeyEntry {
name: string;
apiKey: string;
lineNumber: number;
/** Per-key account id — only populated for providers parsed with `withAccountId` (Cloudflare). */
accountId?: string;
}
export interface BulkApiKeyParseResult {
@@ -21,9 +30,17 @@ export interface BulkApiKeyParseResult {
warnings: string[];
}
export interface ParseBulkApiKeysOptions {
/** Parse each line as the 3-field `name|accountId|apiKey` shape (Cloudflare Workers AI). */
withAccountId?: boolean;
}
const MAX_BULK_LINES = 200;
export function parseBulkApiKeys(text: string): BulkApiKeyParseResult {
export function parseBulkApiKeys(
text: string,
options: ParseBulkApiKeysOptions = {}
): BulkApiKeyParseResult {
const lines = text.split(/\r?\n/);
const entries: BulkApiKeyEntry[] = [];
const warnings: string[] = [];
@@ -41,6 +58,35 @@ export function parseBulkApiKeys(text: string): BulkApiKeyParseResult {
if (!raw) continue;
if (raw.startsWith("#")) continue;
if (options.withAccountId) {
const firstPipe = raw.indexOf("|");
if (firstPipe === -1) {
warnings.push(`Line ${i + 1}: expected name|accountId|apiKey, skipped`);
continue;
}
const secondPipe = raw.indexOf("|", firstPipe + 1);
if (secondPipe === -1) {
warnings.push(`Line ${i + 1}: missing accountId or apiKey, skipped`);
continue;
}
const namePart = raw.slice(0, firstPipe).trim();
const accountId = raw.slice(firstPipe + 1, secondPipe).trim();
const apiKey = raw.slice(secondPipe + 1).trim();
const name = namePart || `Key ${autoIdx++}`;
if (!accountId) {
warnings.push(`Line ${i + 1}: empty accountId, skipped`);
continue;
}
if (!apiKey) {
warnings.push(`Line ${i + 1}: empty apiKey, skipped`);
continue;
}
entries.push({ name, accountId, apiKey, lineNumber: i + 1 });
continue;
}
const pipeIdx = raw.indexOf("|");
let name: string;
let apiKey: string;

View File

@@ -92,6 +92,8 @@ export const bulkCreateProviderSchema = z
z.object({
name: z.string().min(1).max(200),
apiKey: z.string().min(1).max(10000),
// Per-key account id — required for cloudflare-ai (enforced in superRefine below).
accountId: z.string().min(1).max(200).optional(),
})
)
.min(1, "entries must contain at least 1 item")
@@ -120,6 +122,19 @@ export const bulkCreateProviderSchema = z
});
}
}
if (data.provider === "cloudflare-ai") {
// Cloudflare Workers AI builds its per-connection URL from accountId, so every
// bulk entry must carry its own non-empty account id (name|accountId|apiKey).
data.entries.forEach((entry, index) => {
if (typeof entry.accountId !== "string" || entry.accountId.trim().length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "accountId is required for cloudflare-ai entries",
path: ["entries", index, "accountId"],
});
}
});
}
});
// ──── Bulk Web-Session Import Schema ────

View File

@@ -0,0 +1,176 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseBulkApiKeys } from "../../src/shared/utils/bulkApiKeyParser.ts";
import { bulkCreateProviderSchema } from "../../src/shared/validation/schemas.ts";
// #6174 — Bulk Add API Keys for Cloudflare Workers AI.
// Cloudflare needs a per-key accountId, so the bulk rail parses a 3-field
// `name|accountId|apiKey` shape and threads a DISTINCT accountId per entry.
// ─────────────────────────────────────────────────────────────────────────────
// (a) 3-field parser — splits name|accountId|apiKey; 1-2 field shape unaffected
// ─────────────────────────────────────────────────────────────────────────────
test("withAccountId: splits name|accountId|apiKey", () => {
const { entries, warnings } = parseBulkApiKeys("prod|acc-123|cf-token-abc", {
withAccountId: true,
});
assert.equal(entries.length, 1);
assert.deepEqual(
{ name: entries[0].name, accountId: entries[0].accountId, apiKey: entries[0].apiKey },
{ name: "prod", accountId: "acc-123", apiKey: "cf-token-abc" }
);
assert.equal(warnings.length, 0);
});
test("withAccountId: trims whitespace around each field", () => {
const { entries } = parseBulkApiKeys(" prod | acc-1 | cf-token ", {
withAccountId: true,
});
assert.equal(entries.length, 1);
assert.equal(entries[0].name, "prod");
assert.equal(entries[0].accountId, "acc-1");
assert.equal(entries[0].apiKey, "cf-token");
});
test("withAccountId: only the first two pipes are separators — apiKey keeps its own '|'", () => {
const { entries } = parseBulkApiKeys("prod|acc-1|cf|token|with|pipes", {
withAccountId: true,
});
assert.equal(entries.length, 1);
assert.equal(entries[0].accountId, "acc-1");
assert.equal(entries[0].apiKey, "cf|token|with|pipes");
});
test("withAccountId: auto-names when the name field is empty", () => {
const { entries } = parseBulkApiKeys("|acc-1|cf-token", { withAccountId: true });
assert.equal(entries.length, 1);
assert.equal(entries[0].name, "Key 1");
assert.equal(entries[0].accountId, "acc-1");
});
test("withAccountId: flags & skips a line missing the accountId/apiKey field", () => {
const { entries, warnings } = parseBulkApiKeys("prod|only-two-fields", {
withAccountId: true,
});
assert.equal(entries.length, 0);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /Line 1/);
});
test("withAccountId: flags & skips a line with no pipe at all", () => {
const { entries, warnings } = parseBulkApiKeys("justonefield", { withAccountId: true });
assert.equal(entries.length, 0);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /name\|accountId\|apiKey/);
});
test("withAccountId: flags & skips empty accountId or empty apiKey", () => {
const emptyAccount = parseBulkApiKeys("prod||cf-token", { withAccountId: true });
assert.equal(emptyAccount.entries.length, 0);
assert.match(emptyAccount.warnings[0], /accountId/);
const emptyKey = parseBulkApiKeys("prod|acc-1|", { withAccountId: true });
assert.equal(emptyKey.entries.length, 0);
assert.match(emptyKey.warnings[0], /apiKey/);
});
test("withAccountId: skips blank lines and # comments, still caps at 200", () => {
const good = parseBulkApiKeys("\n# a comment\nprod|acc-1|cf-token\n\n", {
withAccountId: true,
});
assert.equal(good.entries.length, 1);
assert.equal(good.entries[0].name, "prod");
const overCap = Array.from({ length: 205 }, (_, i) => `n${i}|acc-${i}|key-${i}`).join("\n");
const capped = parseBulkApiKeys(overCap, { withAccountId: true });
assert.equal(capped.entries.length, 200);
assert.ok(capped.warnings.some((w) => /200/.test(w)));
});
test("default (no options): non-cloudflare providers keep 1-2 field parsing, no accountId", () => {
const { entries } = parseBulkApiKeys("prod|sk-key-1\nsk-key-only");
assert.equal(entries.length, 2);
assert.equal(entries[0].name, "prod");
assert.equal(entries[0].apiKey, "sk-key-1");
assert.equal(entries[0].accountId, undefined);
assert.equal(entries[1].name, "Key 1");
assert.equal(entries[1].apiKey, "sk-key-only");
assert.equal(entries[1].accountId, undefined);
});
test("default: a pipe in a 2-field line is kept in the apiKey (first-pipe split)", () => {
const { entries } = parseBulkApiKeys("prod|sk|has|pipes");
assert.equal(entries.length, 1);
assert.equal(entries[0].apiKey, "sk|has|pipes");
});
// ─────────────────────────────────────────────────────────────────────────────
// (b) Per-entry providerSpecificData — DISTINCT accountId per entry, no shared-
// object bleed. This mirrors the exact merge performed in
// src/app/api/providers/bulk/route.ts (the core bug the feature fixes: the
// route previously reused ONE shared providerSpecificData object for every
// entry, so a per-key accountId would bleed across all connections).
// ─────────────────────────────────────────────────────────────────────────────
test("per-entry PSD: each Cloudflare entry yields its own distinct accountId (no bleed)", () => {
const { entries } = parseBulkApiKeys("k1|acc-AAA|token-1\nk2|acc-BBB|token-2", {
withAccountId: true,
});
assert.equal(entries.length, 2);
// Base PSD shared across the batch (e.g. baseUrl) — must NOT be mutated/reused.
const baseProviderSpecificData: Record<string, unknown> = { baseUrl: "https://api.cf" };
// Exact per-entry merge from route.ts:
const perEntryPsd = entries.map((entry) => ({
...baseProviderSpecificData,
...(entry.accountId ? { accountId: entry.accountId } : {}),
}));
// Distinct accountId per entry.
assert.equal(perEntryPsd[0].accountId, "acc-AAA");
assert.equal(perEntryPsd[1].accountId, "acc-BBB");
// No shared-object bleed: distinct references, and mutating one leaves the
// other + the base untouched.
assert.notEqual(perEntryPsd[0], perEntryPsd[1]);
assert.notEqual(perEntryPsd[0], baseProviderSpecificData);
(perEntryPsd[0] as Record<string, unknown>).accountId = "MUTATED";
assert.equal(perEntryPsd[1].accountId, "acc-BBB");
assert.equal(baseProviderSpecificData.accountId, undefined);
});
// ─────────────────────────────────────────────────────────────────────────────
// Schema: cloudflare-ai requires accountId per entry; other providers unaffected
// ─────────────────────────────────────────────────────────────────────────────
test("schema: cloudflare-ai bulk accepts entries carrying accountId", () => {
const result = bulkCreateProviderSchema.safeParse({
provider: "cloudflare-ai",
entries: [
{ name: "k1", apiKey: "token-1", accountId: "acc-AAA" },
{ name: "k2", apiKey: "token-2", accountId: "acc-BBB" },
],
});
assert.equal(result.success, true);
});
test("schema: cloudflare-ai bulk rejects an entry missing accountId", () => {
const result = bulkCreateProviderSchema.safeParse({
provider: "cloudflare-ai",
entries: [
{ name: "k1", apiKey: "token-1", accountId: "acc-AAA" },
{ name: "k2", apiKey: "token-2" },
],
});
assert.equal(result.success, false);
});
test("schema: accountId stays optional for non-cloudflare providers", () => {
const result = bulkCreateProviderSchema.safeParse({
provider: "anthropic",
entries: [{ name: "prod", apiKey: "sk-1" }],
});
assert.equal(result.success, true);
});

View File

@@ -93,12 +93,15 @@ test("supportsBulkApiKey: false for OAuth/multi-field/web-session providers", ()
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: true for cloudflare-ai (bulk 3-field accountId support, #6174)", () => {
assert.equal(supportsBulkApiKey("cloudflare-ai"), true);
});
test("supportsBulkApiKey: false for non-string/empty input", () => {
assert.equal(supportsBulkApiKey(""), false);
assert.equal(supportsBulkApiKey(null), false);