feat: add cloudflare ai gateway provider

This commit is contained in:
Peter Steinberger
2026-02-04 04:10:13 -08:00
parent 19ecdce275
commit 5b0851ebd8
28 changed files with 663 additions and 9 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan.
- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.
- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.

View File

@@ -535,5 +535,5 @@ Thanks to all clawtributors:
<a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YangHuang2280"><img src="https://avatars.githubusercontent.com/u/201681634?v=4&s=48" width="48" height="48" alt="YangHuang2280" title="YangHuang2280"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/yevhen"><img src="https://avatars.githubusercontent.com/u/107726?v=4&s=48" width="48" height="48" alt="yevhen" title="yevhen"/></a> <a href="https://github.com/YiWang24"><img src="https://avatars.githubusercontent.com/u/176262341?v=4&s=48" width="48" height="48" alt="YiWang24" title="YiWang24"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a>
<a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/zackerthescar"><img src="https://avatars.githubusercontent.com/u/38077284?v=4&s=48" width="48" height="48" alt="zackerthescar" title="zackerthescar"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a>
<a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/roerohan"><img src="https://avatars.githubusercontent.com/u/42958812?v=4&s=48" width="48" height="48" alt="roerohan" title="roerohan"/></a>
</p>

View File

@@ -0,0 +1,71 @@
---
title: "Cloudflare AI Gateway"
summary: "Cloudflare AI Gateway setup (auth + model selection)"
read_when:
- You want to use Cloudflare AI Gateway with OpenClaw
- You need the account ID, gateway ID, or API key env var
---
# Cloudflare AI Gateway
Cloudflare AI Gateway sits in front of provider APIs and lets you add analytics, caching, and controls. For Anthropic, OpenClaw uses the Anthropic Messages API through your Gateway endpoint.
- Provider: `cloudflare-ai-gateway`
- Base URL: `https://gateway.ai.cloudflare.com/v1/<account_id>/<gateway_id>/anthropic`
- Default model: `cloudflare-ai-gateway/claude-sonnet-4-5`
- API key: `CLOUDFLARE_AI_GATEWAY_API_KEY` (your provider API key for requests through the Gateway)
For Anthropic models, use your Anthropic API key.
## Quick start
1. Set the provider API key and Gateway details:
```bash
openclaw onboard --auth-choice cloudflare-ai-gateway-api-key
```
2. Set a default model:
```json5
{
agents: {
defaults: {
model: { primary: "cloudflare-ai-gateway/claude-sonnet-4-5" },
},
},
}
```
## Non-interactive example
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice cloudflare-ai-gateway-api-key \
--cloudflare-ai-gateway-account-id "your-account-id" \
--cloudflare-ai-gateway-gateway-id "your-gateway-id" \
--cloudflare-ai-gateway-api-key "$CLOUDFLARE_AI_GATEWAY_API_KEY"
```
## Authenticated gateways
If you enabled Gateway authentication in Cloudflare, add the `cf-aig-authorization` header (this is in addition to your provider API key).
```json5
{
models: {
providers: {
"cloudflare-ai-gateway": {
headers: {
"cf-aig-authorization": "Bearer <cloudflare-ai-gateway-token>",
},
},
},
},
}
```
## Environment note
If the Gateway runs as a daemon (launchd/systemd), make sure `CLOUDFLARE_AI_GATEWAY_API_KEY` is available to that process (for example, in `~/.openclaw/.env` or via `env.shellEnv`).

View File

@@ -40,6 +40,7 @@ See [Venice AI](/providers/venice).
- [Qwen (OAuth)](/providers/qwen)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
- [OpenCode Zen](/providers/opencode)
- [Amazon Bedrock](/bedrock)

View File

@@ -37,6 +37,7 @@ See [Venice AI](/providers/venice).
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
- [Synthetic](/providers/synthetic)
- [OpenCode Zen](/providers/opencode)

View File

@@ -95,6 +95,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- **API key**: stores the key for you.
- **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`.
- More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway)
- **Cloudflare AI Gateway**: prompts for Account ID, Gateway ID, and `CLOUDFLARE_AI_GATEWAY_API_KEY`.
- More detail: [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- **MiniMax M2.1**: config is auto-written.
- More detail: [MiniMax](/providers/minimax)
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
@@ -239,6 +241,19 @@ openclaw onboard --non-interactive \
--gateway-bind loopback
```
Cloudflare AI Gateway example:
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice cloudflare-ai-gateway-api-key \
--cloudflare-ai-gateway-account-id "your-account-id" \
--cloudflare-ai-gateway-gateway-id "your-gateway-id" \
--cloudflare-ai-gateway-api-key "$CLOUDFLARE_AI_GATEWAY_API_KEY" \
--gateway-port 18789 \
--gateway-bind loopback
```
Moonshot example:
```bash

View File

@@ -169,7 +169,11 @@ export async function resolveApiKeyForProfile(params: {
}
if (cred.type === "api_key") {
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
const key = cred.key?.trim();
if (!key) {
return null;
}
return { apiKey: key, provider: cred.provider, email: cred.email };
}
if (cred.type === "token") {
const token = cred.token?.trim();

View File

@@ -4,8 +4,10 @@ import type { OpenClawConfig } from "../../config/config.js";
export type ApiKeyCredential = {
type: "api_key";
provider: string;
key: string;
key?: string;
email?: string;
/** Optional provider-specific metadata (e.g., account IDs, gateway IDs). */
metadata?: Record<string, string>;
};
export type TokenCredential = {

View File

@@ -0,0 +1,44 @@
import type { ModelDefinitionConfig } from "../config/types.js";
export const CLOUDFLARE_AI_GATEWAY_PROVIDER_ID = "cloudflare-ai-gateway";
export const CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_ID = "claude-sonnet-4-5";
export const CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF = `${CLOUDFLARE_AI_GATEWAY_PROVIDER_ID}/${CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_ID}`;
export const CLOUDFLARE_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW = 200_000;
export const CLOUDFLARE_AI_GATEWAY_DEFAULT_MAX_TOKENS = 64_000;
export const CLOUDFLARE_AI_GATEWAY_DEFAULT_COST = {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
};
export function buildCloudflareAiGatewayModelDefinition(params?: {
id?: string;
name?: string;
reasoning?: boolean;
input?: Array<"text" | "image">;
}): ModelDefinitionConfig {
const id = params?.id?.trim() || CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_ID;
return {
id,
name: params?.name ?? "Claude Sonnet 4.5",
reasoning: params?.reasoning ?? true,
input: params?.input ?? ["text", "image"],
cost: CLOUDFLARE_AI_GATEWAY_DEFAULT_COST,
contextWindow: CLOUDFLARE_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW,
maxTokens: CLOUDFLARE_AI_GATEWAY_DEFAULT_MAX_TOKENS,
};
}
export function resolveCloudflareAiGatewayBaseUrl(params: {
accountId: string;
gatewayId: string;
}): string {
const accountId = params.accountId.trim();
const gatewayId = params.gatewayId.trim();
if (!accountId || !gatewayId) {
return "";
}
return `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/anthropic`;
}

View File

@@ -293,6 +293,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
"cloudflare-ai-gateway": "CLOUDFLARE_AI_GATEWAY_API_KEY",
moonshot: "MOONSHOT_API_KEY",
minimax: "MINIMAX_API_KEY",
xiaomi: "XIAOMI_API_KEY",

View File

@@ -6,6 +6,10 @@ import {
} from "../providers/github-copilot-token.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { discoverBedrockModels } from "./bedrock-discovery.js";
import {
buildCloudflareAiGatewayModelDefinition,
resolveCloudflareAiGatewayBaseUrl,
} from "./cloudflare-ai-gateway.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import {
buildSyntheticModelDefinition,
@@ -453,6 +457,34 @@ export async function resolveImplicitProviders(params: {
providers.xiaomi = { ...buildXiaomiProvider(), apiKey: xiaomiKey };
}
const cloudflareProfiles = listProfilesForProvider(authStore, "cloudflare-ai-gateway");
for (const profileId of cloudflareProfiles) {
const cred = authStore.profiles[profileId];
if (cred?.type !== "api_key") {
continue;
}
const accountId = cred.metadata?.accountId?.trim();
const gatewayId = cred.metadata?.gatewayId?.trim();
if (!accountId || !gatewayId) {
continue;
}
const baseUrl = resolveCloudflareAiGatewayBaseUrl({ accountId, gatewayId });
if (!baseUrl) {
continue;
}
const apiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway") ?? cred.key?.trim() ?? "";
if (!apiKey) {
continue;
}
providers["cloudflare-ai-gateway"] = {
baseUrl,
api: "anthropic-messages",
apiKey,
models: [buildCloudflareAiGatewayModelDefinition()],
};
break;
}
// Ollama provider - only add if explicitly configured
const ollamaKey =
resolveEnvApiKeyVarName("ollama") ??

View File

@@ -104,7 +104,7 @@ function resolveModelAuthLabel(params: {
if (profile.type === "token") {
return `token ${formatApiKeySnippet(profile.token)}${label ? ` (${label})` : ""}`;
}
return `api-key ${formatApiKeySnippet(profile.key)}${label ? ` (${label})` : ""}`;
return `api-key ${formatApiKeySnippet(profile.key ?? "")}${label ? ` (${label})` : ""}`;
}
const envKey = resolveEnvApiKey(providerKey);

View File

@@ -80,7 +80,7 @@ function resolveModelAuthLabel(
const snippet = formatApiKeySnippet(profile.token);
return `token ${snippet}${label ? ` (${label})` : ""}`;
}
const snippet = formatApiKeySnippet(profile.key);
const snippet = formatApiKeySnippet(profile.key ?? "");
return `api-key ${snippet}${label ? ` (${label})` : ""}`;
}

View File

@@ -93,7 +93,7 @@ export const resolveAuthLabel = async (
if (profile.type === "api_key") {
return {
label: `${profileId} api-key ${maskApiKey(profile.key)}${more}`,
label: `${profileId} api-key ${maskApiKey(profile.key ?? "")}${more}`,
source: "",
};
}
@@ -154,7 +154,7 @@ export const resolveAuthLabel = async (
}
if (profile.type === "api_key") {
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
return `${profileId}=${maskApiKey(profile.key)}${suffix}`;
return `${profileId}=${maskApiKey(profile.key ?? "")}${suffix}`;
}
if (profile.type === "token") {
if (

View File

@@ -58,7 +58,7 @@ export function registerOnboardCommand(program: Command) {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
"Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|cloudflare-ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
)
.option(
"--token-provider <id>",
@@ -74,6 +74,9 @@ export function registerOnboardCommand(program: Command) {
.option("--openai-api-key <key>", "OpenAI API key")
.option("--openrouter-api-key <key>", "OpenRouter API key")
.option("--ai-gateway-api-key <key>", "Vercel AI Gateway API key")
.option("--cloudflare-ai-gateway-account-id <id>", "Cloudflare Account ID")
.option("--cloudflare-ai-gateway-gateway-id <id>", "Cloudflare AI Gateway ID")
.option("--cloudflare-ai-gateway-api-key <key>", "Cloudflare AI Gateway API key")
.option("--moonshot-api-key <key>", "Moonshot API key")
.option("--kimi-code-api-key <key>", "Kimi Coding API key")
.option("--gemini-api-key <key>", "Gemini API key")
@@ -125,6 +128,9 @@ export function registerOnboardCommand(program: Command) {
openaiApiKey: opts.openaiApiKey as string | undefined,
openrouterApiKey: opts.openrouterApiKey as string | undefined,
aiGatewayApiKey: opts.aiGatewayApiKey as string | undefined,
cloudflareAiGatewayAccountId: opts.cloudflareAiGatewayAccountId as string | undefined,
cloudflareAiGatewayGatewayId: opts.cloudflareAiGatewayGatewayId as string | undefined,
cloudflareAiGatewayApiKey: opts.cloudflareAiGatewayApiKey as string | undefined,
moonshotApiKey: opts.moonshotApiKey as string | undefined,
kimiCodeApiKey: opts.kimiCodeApiKey as string | undefined,
geminiApiKey: opts.geminiApiKey as string | undefined,

View File

@@ -75,6 +75,16 @@ describe("buildAuthChoiceOptions", () => {
expect(options.some((opt) => opt.value === "ai-gateway-api-key")).toBe(true);
});
it("includes Cloudflare AI Gateway auth choice", () => {
const store: AuthProfileStore = { version: 1, profiles: {} };
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
});
expect(options.some((opt) => opt.value === "cloudflare-ai-gateway-api-key")).toBe(true);
});
it("includes Synthetic auth choice", () => {
const store: AuthProfileStore = { version: 1, profiles: {} };
const options = buildAuthChoiceOptions({

View File

@@ -14,6 +14,7 @@ export type AuthChoiceGroupId =
| "copilot"
| "openrouter"
| "ai-gateway"
| "cloudflare-ai-gateway"
| "moonshot"
| "zai"
| "xiaomi"
@@ -120,6 +121,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
hint: "Privacy-focused (uncensored models)",
choices: ["venice-api-key"],
},
{
value: "cloudflare-ai-gateway",
label: "Cloudflare AI Gateway",
hint: "Account ID + Gateway ID + API key",
choices: ["cloudflare-ai-gateway-api-key"],
},
];
export function buildAuthChoiceOptions(params: {
@@ -146,6 +153,11 @@ export function buildAuthChoiceOptions(params: {
value: "ai-gateway-api-key",
label: "Vercel AI Gateway API key",
});
options.push({
value: "cloudflare-ai-gateway-api-key",
label: "Cloudflare AI Gateway",
hint: "Account ID + Gateway ID + API key",
});
options.push({
value: "moonshot-api-key",
label: "Kimi API key (.ai)",

View File

@@ -13,6 +13,8 @@ import {
} from "./google-gemini-model-default.js";
import {
applyAuthProfileConfig,
applyCloudflareAiGatewayConfig,
applyCloudflareAiGatewayProviderConfig,
applyKimiCodeConfig,
applyKimiCodeProviderConfig,
applyMoonshotConfig,
@@ -32,6 +34,7 @@ import {
applyXiaomiConfig,
applyXiaomiProviderConfig,
applyZaiConfig,
CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
KIMI_CODING_MODEL_REF,
MOONSHOT_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF,
@@ -39,6 +42,7 @@ import {
VENICE_DEFAULT_MODEL_REF,
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
XIAOMI_DEFAULT_MODEL_REF,
setCloudflareAiGatewayConfig,
setGeminiApiKey,
setKimiCodingApiKey,
setMoonshotApiKey,
@@ -79,6 +83,8 @@ export async function applyAuthChoiceApiProviders(
authChoice = "openrouter-api-key";
} else if (params.opts.tokenProvider === "vercel-ai-gateway") {
authChoice = "ai-gateway-api-key";
} else if (params.opts.tokenProvider === "cloudflare-ai-gateway") {
authChoice = "cloudflare-ai-gateway-api-key";
} else if (params.opts.tokenProvider === "moonshot") {
authChoice = "moonshot-api-key";
} else if (
@@ -231,6 +237,105 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (authChoice === "cloudflare-ai-gateway-api-key") {
let hasCredential = false;
let accountId = params.opts?.cloudflareAiGatewayAccountId?.trim() ?? "";
let gatewayId = params.opts?.cloudflareAiGatewayGatewayId?.trim() ?? "";
const ensureAccountGateway = async () => {
if (!accountId) {
const value = await params.prompter.text({
message: "Enter Cloudflare Account ID",
validate: (val) => (String(val).trim() ? undefined : "Account ID is required"),
});
accountId = String(value).trim();
}
if (!gatewayId) {
const value = await params.prompter.text({
message: "Enter Cloudflare AI Gateway ID",
validate: (val) => (String(val).trim() ? undefined : "Gateway ID is required"),
});
gatewayId = String(value).trim();
}
};
const optsApiKey = normalizeApiKeyInput(params.opts?.cloudflareAiGatewayApiKey ?? "");
if (!hasCredential && accountId && gatewayId && optsApiKey) {
await setCloudflareAiGatewayConfig(accountId, gatewayId, optsApiKey, params.agentDir);
hasCredential = true;
}
const envKey = resolveEnvApiKey("cloudflare-ai-gateway");
if (!hasCredential && envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing CLOUDFLARE_AI_GATEWAY_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
await ensureAccountGateway();
await setCloudflareAiGatewayConfig(
accountId,
gatewayId,
normalizeApiKeyInput(envKey.apiKey),
params.agentDir,
);
hasCredential = true;
}
}
if (!hasCredential && optsApiKey) {
await ensureAccountGateway();
await setCloudflareAiGatewayConfig(accountId, gatewayId, optsApiKey, params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
await ensureAccountGateway();
const key = await params.prompter.text({
message: "Enter Cloudflare AI Gateway API key",
validate: validateApiKeyInput,
});
await setCloudflareAiGatewayConfig(
accountId,
gatewayId,
normalizeApiKeyInput(String(key)),
params.agentDir,
);
hasCredential = true;
}
if (hasCredential) {
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "cloudflare-ai-gateway:default",
provider: "cloudflare-ai-gateway",
mode: "api_key",
});
}
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
applyDefaultConfig: (cfg) =>
applyCloudflareAiGatewayConfig(cfg, {
accountId: accountId || params.opts?.cloudflareAiGatewayAccountId,
gatewayId: gatewayId || params.opts?.cloudflareAiGatewayGatewayId,
}),
applyProviderConfig: (cfg) =>
applyCloudflareAiGatewayProviderConfig(cfg, {
accountId: accountId || params.opts?.cloudflareAiGatewayAccountId,
gatewayId: gatewayId || params.opts?.cloudflareAiGatewayGatewayId,
}),
noteDefault: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
return { config: nextConfig, agentModelOverride };
}
if (authChoice === "moonshot-api-key") {
let hasCredential = false;

View File

@@ -24,6 +24,9 @@ export type ApplyAuthChoiceParams = {
opts?: {
tokenProvider?: string;
token?: string;
cloudflareAiGatewayAccountId?: string;
cloudflareAiGatewayGatewayId?: string;
cloudflareAiGatewayApiKey?: string;
};
};

View File

@@ -12,6 +12,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"openai-api-key": "openai",
"openrouter-api-key": "openrouter",
"ai-gateway-api-key": "vercel-ai-gateway",
"cloudflare-ai-gateway-api-key": "cloudflare-ai-gateway",
"moonshot-api-key": "moonshot",
"moonshot-api-key-cn": "moonshot",
"kimi-code-api-key": "kimi-coding",

View File

@@ -33,6 +33,7 @@ describe("applyAuthChoice", () => {
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
const previousOpenrouterKey = process.env.OPENROUTER_API_KEY;
const previousAiGatewayKey = process.env.AI_GATEWAY_API_KEY;
const previousCloudflareGatewayKey = process.env.CLOUDFLARE_AI_GATEWAY_API_KEY;
const previousSshTty = process.env.SSH_TTY;
const previousChutesClientId = process.env.CHUTES_CLIENT_ID;
let tempStateDir: string | null = null;
@@ -69,6 +70,11 @@ describe("applyAuthChoice", () => {
} else {
process.env.AI_GATEWAY_API_KEY = previousAiGatewayKey;
}
if (previousCloudflareGatewayKey === undefined) {
delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY;
} else {
process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = previousCloudflareGatewayKey;
}
if (previousSshTty === undefined) {
delete process.env.SSH_TTY;
} else {
@@ -405,6 +411,76 @@ describe("applyAuthChoice", () => {
delete process.env.AI_GATEWAY_API_KEY;
});
it("uses existing CLOUDFLARE_AI_GATEWAY_API_KEY when selecting cloudflare-ai-gateway-api-key", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent");
process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR;
process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = "cf-gateway-test-key";
const text = vi
.fn()
.mockResolvedValueOnce("cf-account-id")
.mockResolvedValueOnce("cf-gateway-id");
const select: WizardPrompter["select"] = vi.fn(
async (params) => params.options[0]?.value as never,
);
const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []);
const confirm = vi.fn(async () => true);
const prompter: WizardPrompter = {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select,
multiselect,
text,
confirm,
progress: vi.fn(() => ({ update: noop, stop: noop })),
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
const result = await applyAuthChoice({
authChoice: "cloudflare-ai-gateway-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("CLOUDFLARE_AI_GATEWAY_API_KEY"),
}),
);
expect(text).toHaveBeenCalledTimes(2);
expect(result.config.auth?.profiles?.["cloudflare-ai-gateway:default"]).toMatchObject({
provider: "cloudflare-ai-gateway",
mode: "api_key",
});
expect(result.config.agents?.defaults?.model?.primary).toBe(
"cloudflare-ai-gateway/claude-sonnet-4-5",
);
const authProfilePath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string; metadata?: Record<string, string> }>;
};
expect(parsed.profiles?.["cloudflare-ai-gateway:default"]?.key).toBe("cf-gateway-test-key");
expect(parsed.profiles?.["cloudflare-ai-gateway:default"]?.metadata).toEqual({
accountId: "cf-account-id",
gatewayId: "cf-gateway-id",
});
delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY;
});
it("writes Chutes OAuth credentials when selecting chutes (remote/manual)", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;

View File

@@ -40,7 +40,7 @@ export function resolveProviderAuthOverview(params: {
return `${profileId}=missing`;
}
if (profile.type === "api_key") {
return withUnusableSuffix(`${profileId}=${maskApiKey(profile.key)}`, profileId);
return withUnusableSuffix(`${profileId}=${maskApiKey(profile.key ?? "")}`, profileId);
}
if (profile.type === "token") {
return withUnusableSuffix(`${profileId}=token:${maskApiKey(profile.token)}`, profileId);

View File

@@ -1,4 +1,8 @@
import type { OpenClawConfig } from "../config/config.js";
import {
buildCloudflareAiGatewayModelDefinition,
resolveCloudflareAiGatewayBaseUrl,
} from "../agents/cloudflare-ai-gateway.js";
import { buildXiaomiProvider, XIAOMI_DEFAULT_MODEL_ID } from "../agents/models-config.providers.js";
import {
buildSyntheticModelDefinition,
@@ -13,6 +17,7 @@ import {
VENICE_MODEL_CATALOG,
} from "../agents/venice-models.js";
import {
CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF,
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
XIAOMI_DEFAULT_MODEL_REF,
@@ -93,6 +98,73 @@ export function applyVercelAiGatewayProviderConfig(cfg: OpenClawConfig): OpenCla
};
}
export function applyCloudflareAiGatewayProviderConfig(
cfg: OpenClawConfig,
params?: { accountId?: string; gatewayId?: string },
): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF] = {
...models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF],
alias: models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Cloudflare AI Gateway",
};
const providers = { ...cfg.models?.providers };
const existingProvider = providers["cloudflare-ai-gateway"];
const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : [];
const defaultModel = buildCloudflareAiGatewayModelDefinition();
const hasDefaultModel = existingModels.some((model) => model.id === defaultModel.id);
const mergedModels = hasDefaultModel ? existingModels : [...existingModels, defaultModel];
const baseUrl =
params?.accountId && params?.gatewayId
? resolveCloudflareAiGatewayBaseUrl({
accountId: params.accountId,
gatewayId: params.gatewayId,
})
: existingProvider?.baseUrl;
if (!baseUrl) {
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
string,
unknown
> as { apiKey?: string };
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
const normalizedApiKey = resolvedApiKey?.trim();
providers["cloudflare-ai-gateway"] = {
...existingProviderRest,
baseUrl,
api: "anthropic-messages",
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : [defaultModel],
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: {
mode: cfg.models?.mode ?? "merge",
providers,
},
};
}
export function applyVercelAiGatewayConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyVercelAiGatewayProviderConfig(cfg);
const existingModel = next.agents?.defaults?.model;
@@ -115,6 +187,31 @@ export function applyVercelAiGatewayConfig(cfg: OpenClawConfig): OpenClawConfig
};
}
export function applyCloudflareAiGatewayConfig(
cfg: OpenClawConfig,
params?: { accountId?: string; gatewayId?: string },
): OpenClawConfig {
const next = applyCloudflareAiGatewayProviderConfig(cfg, params);
const existingModel = next.agents?.defaults?.model;
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
},
},
},
};
}
export function applyOpenrouterConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyOpenrouterProviderConfig(cfg);
const existingModel = next.agents?.defaults?.model;

View File

@@ -1,6 +1,7 @@
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js";
const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir();
@@ -155,6 +156,30 @@ export async function setOpenrouterApiKey(key: string, agentDir?: string) {
});
}
export async function setCloudflareAiGatewayConfig(
accountId: string,
gatewayId: string,
apiKey: string,
agentDir?: string,
) {
const normalizedAccountId = accountId.trim();
const normalizedGatewayId = gatewayId.trim();
const normalizedKey = apiKey.trim();
upsertAuthProfile({
profileId: "cloudflare-ai-gateway:default",
credential: {
type: "api_key",
provider: "cloudflare-ai-gateway",
key: normalizedKey,
metadata: {
accountId: normalizedAccountId,
gatewayId: normalizedGatewayId,
},
},
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setVercelAiGatewayApiKey(key: string, agentDir?: string) {
upsertAuthProfile({
profileId: "vercel-ai-gateway:default",

View File

@@ -5,6 +5,8 @@ export {
export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js";
export {
applyAuthProfileConfig,
applyCloudflareAiGatewayConfig,
applyCloudflareAiGatewayProviderConfig,
applyKimiCodeConfig,
applyKimiCodeProviderConfig,
applyMoonshotConfig,
@@ -37,8 +39,10 @@ export {
applyOpencodeZenProviderConfig,
} from "./onboard-auth.config-opencode.js";
export {
CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF,
setAnthropicApiKey,
setCloudflareAiGatewayConfig,
setGeminiApiKey,
setKimiCodingApiKey,
setMinimaxApiKey,

View File

@@ -0,0 +1,99 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
describe("onboard (non-interactive): Cloudflare AI Gateway", () => {
it("stores the API key and configures the default model", async () => {
const prev = {
home: process.env.HOME,
stateDir: process.env.OPENCLAW_STATE_DIR,
configPath: process.env.OPENCLAW_CONFIG_PATH,
skipChannels: process.env.OPENCLAW_SKIP_CHANNELS,
skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER,
skipCron: process.env.OPENCLAW_SKIP_CRON,
skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
token: process.env.OPENCLAW_GATEWAY_TOKEN,
password: process.env.OPENCLAW_GATEWAY_PASSWORD,
};
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-cf-gateway-"));
process.env.HOME = tempHome;
process.env.OPENCLAW_STATE_DIR = tempHome;
process.env.OPENCLAW_CONFIG_PATH = path.join(tempHome, "openclaw.json");
vi.resetModules();
const runtime = {
log: () => {},
error: (msg: string) => {
throw new Error(msg);
},
exit: (code: number) => {
throw new Error(`exit:${code}`);
},
};
try {
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
await runNonInteractiveOnboarding(
{
nonInteractive: true,
authChoice: "cloudflare-ai-gateway-api-key",
cloudflareAiGatewayAccountId: "cf-account-id",
cloudflareAiGatewayGatewayId: "cf-gateway-id",
cloudflareAiGatewayApiKey: "cf-gateway-test-key",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const { CONFIG_PATH } = await import("../config/config.js");
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")) as {
auth?: {
profiles?: Record<string, { provider?: string; mode?: string }>;
};
agents?: { defaults?: { model?: { primary?: string } } };
};
expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.provider).toBe(
"cloudflare-ai-gateway",
);
expect(cfg.auth?.profiles?.["cloudflare-ai-gateway:default"]?.mode).toBe("api_key");
expect(cfg.agents?.defaults?.model?.primary).toBe("cloudflare-ai-gateway/claude-sonnet-4-5");
const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js");
const store = ensureAuthProfileStore();
const profile = store.profiles["cloudflare-ai-gateway:default"];
expect(profile?.type).toBe("api_key");
if (profile?.type === "api_key") {
expect(profile.provider).toBe("cloudflare-ai-gateway");
expect(profile.key).toBe("cf-gateway-test-key");
expect(profile.metadata).toEqual({
accountId: "cf-account-id",
gatewayId: "cf-gateway-id",
});
}
} finally {
await fs.rm(tempHome, { recursive: true, force: true });
process.env.HOME = prev.home;
process.env.OPENCLAW_STATE_DIR = prev.stateDir;
process.env.OPENCLAW_CONFIG_PATH = prev.configPath;
process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels;
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.OPENCLAW_SKIP_CRON = prev.skipCron;
process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas;
process.env.OPENCLAW_GATEWAY_TOKEN = prev.token;
process.env.OPENCLAW_GATEWAY_PASSWORD = prev.password;
}
}, 60_000);
});

View File

@@ -10,6 +10,7 @@ import { buildTokenProfileId, validateAnthropicSetupToken } from "../../auth-tok
import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js";
import {
applyAuthProfileConfig,
applyCloudflareAiGatewayConfig,
applyKimiCodeConfig,
applyMinimaxApiConfig,
applyMinimaxConfig,
@@ -23,6 +24,7 @@ import {
applyXiaomiConfig,
applyZaiConfig,
setAnthropicApiKey,
setCloudflareAiGatewayConfig,
setGeminiApiKey,
setKimiCodingApiKey,
setMinimaxApiKey,
@@ -281,6 +283,44 @@ export async function applyNonInteractiveAuthChoice(params: {
return applyVercelAiGatewayConfig(nextConfig);
}
if (authChoice === "cloudflare-ai-gateway-api-key") {
const accountId = opts.cloudflareAiGatewayAccountId?.trim() ?? "";
const gatewayId = opts.cloudflareAiGatewayGatewayId?.trim() ?? "";
if (!accountId || !gatewayId) {
runtime.error(
[
'Auth choice "cloudflare-ai-gateway-api-key" requires Account ID and Gateway ID.',
"Use --cloudflare-ai-gateway-account-id and --cloudflare-ai-gateway-gateway-id.",
].join("\n"),
);
runtime.exit(1);
return null;
}
const resolved = await resolveNonInteractiveApiKey({
provider: "cloudflare-ai-gateway",
cfg: baseConfig,
flagValue: opts.cloudflareAiGatewayApiKey,
flagName: "--cloudflare-ai-gateway-api-key",
envVar: "CLOUDFLARE_AI_GATEWAY_API_KEY",
runtime,
});
if (!resolved) {
return null;
}
if (resolved.source !== "profile") {
await setCloudflareAiGatewayConfig(accountId, gatewayId, resolved.key);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "cloudflare-ai-gateway:default",
provider: "cloudflare-ai-gateway",
mode: "api_key",
});
return applyCloudflareAiGatewayConfig(nextConfig, {
accountId,
gatewayId,
});
}
if (authChoice === "moonshot-api-key") {
const resolved = await resolveNonInteractiveApiKey({
provider: "moonshot",

View File

@@ -13,6 +13,7 @@ export type AuthChoice =
| "openai-api-key"
| "openrouter-api-key"
| "ai-gateway-api-key"
| "cloudflare-ai-gateway-api-key"
| "moonshot-api-key"
| "moonshot-api-key-cn"
| "kimi-code-api-key"
@@ -66,6 +67,9 @@ export type OnboardOptions = {
openaiApiKey?: string;
openrouterApiKey?: string;
aiGatewayApiKey?: string;
cloudflareAiGatewayAccountId?: string;
cloudflareAiGatewayGatewayId?: string;
cloudflareAiGatewayApiKey?: string;
moonshotApiKey?: string;
kimiCodeApiKey?: string;
geminiApiKey?: string;