diff --git a/CHANGELOG.md b/CHANGELOG.md index 669c7984be..1a862fd82c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 7e24435689..bebf5fcfd7 100644 --- a/README.md +++ b/README.md @@ -535,5 +535,5 @@ Thanks to all clawtributors: voidserf Vultr-Clawd Admin Wimmie wolfred wstock YangHuang2280 yazinsai yevhen YiWang24 ymat19 Zach Knickerbocker zackerthescar 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh Rolf Fredheim ronak-guliani - William Stock + William Stock roerohan

diff --git a/docs/providers/cloudflare-ai-gateway.md b/docs/providers/cloudflare-ai-gateway.md new file mode 100644 index 0000000000..392a611e70 --- /dev/null +++ b/docs/providers/cloudflare-ai-gateway.md @@ -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///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 ", + }, + }, + }, + }, +} +``` + +## 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`). diff --git a/docs/providers/index.md b/docs/providers/index.md index 6009dba15b..cc1dad7ee5 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -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) diff --git a/docs/providers/models.md b/docs/providers/models.md index ad6e424b05..64c7d865ec 100644 --- a/docs/providers/models.md +++ b/docs/providers/models.md @@ -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) diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 501d686a81..1269344fe8 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -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 diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 064b72f549..4fff5a3012 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -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(); diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index 4d08d301d8..f4a0a4e860 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -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; }; export type TokenCredential = { diff --git a/src/agents/cloudflare-ai-gateway.ts b/src/agents/cloudflare-ai-gateway.ts new file mode 100644 index 0000000000..77ed2fdc93 --- /dev/null +++ b/src/agents/cloudflare-ai-gateway.ts @@ -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`; +} diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 4a4b5702cc..ba85e213cc 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -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", diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 6ad93813dd..e49b150c76 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -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") ?? diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 71547a8340..2eded36e96 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -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); diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index d9a5176a25..1695ba627f 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -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})` : ""}`; } diff --git a/src/auto-reply/reply/directive-handling.auth.ts b/src/auto-reply/reply/directive-handling.auth.ts index 1b9ae92f8b..4b25d86b69 100644 --- a/src/auto-reply/reply/directive-handling.auth.ts +++ b/src/auto-reply/reply/directive-handling.auth.ts @@ -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 ( diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 9c9c1634ce..995afbfdcb 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -58,7 +58,7 @@ export function registerOnboardCommand(program: Command) { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-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 ", @@ -74,6 +74,9 @@ export function registerOnboardCommand(program: Command) { .option("--openai-api-key ", "OpenAI API key") .option("--openrouter-api-key ", "OpenRouter API key") .option("--ai-gateway-api-key ", "Vercel AI Gateway API key") + .option("--cloudflare-ai-gateway-account-id ", "Cloudflare Account ID") + .option("--cloudflare-ai-gateway-gateway-id ", "Cloudflare AI Gateway ID") + .option("--cloudflare-ai-gateway-api-key ", "Cloudflare AI Gateway API key") .option("--moonshot-api-key ", "Moonshot API key") .option("--kimi-code-api-key ", "Kimi Coding API key") .option("--gemini-api-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, diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index 2f1e08faa9..2ea1cf6247 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -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({ diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index aa78c7fe9b..c3a281278c 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -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)", diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index ea6095c9cb..6396b6e397 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -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; diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index 37dc0f272e..53b22fdd47 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -24,6 +24,9 @@ export type ApplyAuthChoiceParams = { opts?: { tokenProvider?: string; token?: string; + cloudflareAiGatewayAccountId?: string; + cloudflareAiGatewayGatewayId?: string; + cloudflareAiGatewayApiKey?: string; }; }; diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 14dcf30b28..ac530e169f 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -12,6 +12,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "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", diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index c034b6144a..b13972f7b7 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -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 }>; + }; + 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; diff --git a/src/commands/models/list.auth-overview.ts b/src/commands/models/list.auth-overview.ts index 7bd4014e2b..90c8a0defa 100644 --- a/src/commands/models/list.auth-overview.ts +++ b/src/commands/models/list.auth-overview.ts @@ -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); diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 892d44224f..804035a918 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -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) + ? { + 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; diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index 1790925d7b..8d2dca121e 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -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", diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index e3fc17b822..97483e1ed5 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -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, diff --git a/src/commands/onboard-non-interactive.cloudflare-ai-gateway.test.ts b/src/commands/onboard-non-interactive.cloudflare-ai-gateway.test.ts new file mode 100644 index 0000000000..c3cc5667e8 --- /dev/null +++ b/src/commands/onboard-non-interactive.cloudflare-ai-gateway.test.ts @@ -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; + }; + 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); +}); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index c38002d651..9b69f1dfda 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -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", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 2111e4ff17..ad0406efd1 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -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;