mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
5531fc7f05
commit
776a7a3a58
@@ -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
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ────
|
||||
|
||||
176
tests/unit/bulk-api-key-parser-cloudflare.test.ts
Normal file
176
tests/unit/bulk-api-key-parser-cloudflare.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user