Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(providers):** Check Cloudflare Workers AI credentials with account-scoped model metadata instead of inference; support explicitly configured Global API Key headers and keep ambiguous 403/429 probes neutral.
65 changes: 44 additions & 21 deletions open-sse/executors/cloudflare-ai.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,56 @@
import { BaseExecutor } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";

type CloudflareCredentials = {
export type CloudflareProviderSpecificData = {
accountId?: string;
authType?: string;
email?: string;
};

export type CloudflareCredentials = {
apiKey?: string;
accessToken?: string;
accountId?: string;
providerSpecificData?: {
accountId?: string;
} | null;
providerSpecificData?: CloudflareProviderSpecificData | null;
} | null;

export const CLOUDFLARE_ACCOUNT_ID_REQUIRED =
"Cloudflare Workers AI requires an Account ID. " +
"Add it in provider settings under 'Account ID'. " +
"Find it at: https://dash.cloudflare.com (right sidebar).";

/** Share the executor's account selection with the read-only credential probe. */
export function resolveCloudflareAccountBase(credentials: CloudflareCredentials) {
const accountId =
credentials?.providerSpecificData?.accountId ||
credentials?.accountId ||
process.env.CLOUDFLARE_ACCOUNT_ID;
return {
accountUrl: accountId
? `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}`
: null,
};
}

/** API tokens use Bearer; explicitly selected legacy Global API Keys need both headers. */
export function buildCloudflareAuthHeaders(
credentials: CloudflareCredentials
): Record<string, string> {
const key = credentials?.apiKey || credentials?.accessToken;
if (credentials?.providerSpecificData?.authType === "global") {
const email = credentials.providerSpecificData.email;
if (!email) throw new Error("Cloudflare Global API Key authentication requires an email.");
return { "X-Auth-Email": email, "X-Auth-Key": key || "" };
}
return { Authorization: `Bearer ${key}` };
}

/**
* CloudflareAIExecutor — handles dynamic URL construction with accountId.
* Cloudflare Workers AI uses the authenticated user's account ID in the URL.
*
* URL pattern: https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/v1/chat/completions
* Auth: Bearer <API Token>
* Auth: Bearer <API Token>, or X-Auth-Email + X-Auth-Key for a Global API Key
* Docs: https://developers.cloudflare.com/workers-ai/
*
* Free tier: 10,000 Neurons/day = ~150 LLM responses or 500s Whisper audio
Expand All @@ -33,27 +68,15 @@ export class CloudflareAIExecutor extends BaseExecutor {
_urlIndex = 0,
credentials: CloudflareCredentials = null
): string {
// Account ID can be stored in providerSpecificData or at top level credentials
const accountId =
credentials?.providerSpecificData?.accountId ||
credentials?.accountId ||
process.env.CLOUDFLARE_ACCOUNT_ID;

if (!accountId) {
throw new Error(
"Cloudflare Workers AI requires an Account ID. " +
"Add it in provider settings under 'Account ID'. " +
"Find it at: https://dash.cloudflare.com (right sidebar)."
);
}

return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1/chat/completions`;
const { accountUrl } = resolveCloudflareAccountBase(credentials);
if (!accountUrl) throw new Error(CLOUDFLARE_ACCOUNT_ID_REQUIRED);
return `${accountUrl}/ai/v1/chat/completions`;
}

buildHeaders(credentials: CloudflareCredentials, stream = true): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.apiKey || credentials.accessToken}`,
...buildCloudflareAuthHeaders(credentials),
};

if (stream) {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/providers/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
resolveBaseUrl,
} from "./validation/urlHelpers";
import { toValidationErrorResult } from "./validation/transport";
import { validateCloudflareAiProvider } from "./validation/cloudflareAi";
import {
validateDeepSeekWebProvider,
validateGrokWebProvider,
Expand Down Expand Up @@ -229,6 +230,7 @@ export async function validateProviderApiKey({

// ── Specialty provider validation ──
const SPECIALTY_VALIDATORS = {
"cloudflare-ai": validateCloudflareAiProvider,
"v0-vercel": ({ apiKey, providerSpecificData }: any) =>
validateV0VercelProvider({ apiKey, providerSpecificData, isLocal }),
jules: validateJulesProvider,
Expand Down
89 changes: 89 additions & 0 deletions src/lib/providers/validation/cloudflareAi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
CLOUDFLARE_ACCOUNT_ID_REQUIRED,
buildCloudflareAuthHeaders,
resolveCloudflareAccountBase,
type CloudflareCredentials,
type CloudflareProviderSpecificData,
} from "@omniroute/open-sse/executors/cloudflare-ai.ts";
import { toValidationErrorResult, validationRead } from "./transport";

// Cloudflare error codes for invalid request headers, invalid tokens and authentication failure.
const AUTH_ERROR_CODES = new Set([6003, 9106, 10000]);

type CloudflareAiValidationResult = {
valid: boolean;
error: string | null;
statusCode?: number;
unsupported?: boolean;
};

/** Interpret only recognized metadata/auth envelopes; ambiguous probes must not poison key health. */
export function cloudflareAiValidationResult(
status: number,
body: unknown
): CloudflareAiValidationResult {
const envelope = body as { success?: unknown; result?: unknown; errors?: unknown } | null;
if (status === 200) {
if (envelope?.success === true && Array.isArray(envelope.result)) {
return { valid: true, error: null };
}
return {
valid: false,
unsupported: true,
error: "Cloudflare credential probe returned an unrecognized envelope",
statusCode: status,
};
}
const errors = Array.isArray(envelope?.errors) ? envelope.errors : [];
const authRejected =
status === 401 ||
((status === 400 || status === 403) &&
errors.some((entry: unknown) => {
const code = (entry as { code?: unknown } | null)?.code;
return typeof code === "number" && AUTH_ERROR_CODES.has(code);
}));
if (authRejected) return { valid: false, error: "Invalid API key", statusCode: status };
if (status === 403 || status === 429) {
return {
valid: false,
unsupported: true,
error: `Cloudflare credential probe inconclusive (${status})`,
statusCode: status,
};
}
return { valid: false, error: `Validation failed: ${status}`, statusCode: status };
}

/**
* GET the authenticated account's model metadata, without running inference.
* https://developers.cloudflare.com/api/resources/ai/subresources/models/methods/list/
*/
export async function validateCloudflareAiProvider({
apiKey,
providerSpecificData = {},
}: {
apiKey?: string;
providerSpecificData?: Record<string, unknown>;
}): Promise<CloudflareAiValidationResult> {
const credentials: CloudflareCredentials = {
apiKey,
providerSpecificData: providerSpecificData as CloudflareProviderSpecificData,
};
const { accountUrl } = resolveCloudflareAccountBase(credentials);
if (!accountUrl) return { valid: false, error: CLOUDFLARE_ACCOUNT_ID_REQUIRED };
try {
const response = await validationRead(`${accountUrl}/ai/models/search?per_page=1`, {
method: "GET",
headers: buildCloudflareAuthHeaders(credentials),
});
let body: unknown = null;
try {
body = await response.json();
} catch {
// Non-JSON WAF/transport responses are classified as unrecognized, never accepted as success.
}
return cloudflareAiValidationResult(response.status, body);
} catch (error: unknown) {
return toValidationErrorResult(error);
}
}
Loading
Loading