From c8432c7fe3c30fd01b6505a2afe4951046136a19 Mon Sep 17 00:00:00 2001 From: ebar0n Date: Mon, 9 Feb 2026 22:33:37 -0500 Subject: [PATCH 1/2] refactor: update AI Gateway model configuration to support multiple models Changed the environment variable from `CF_AI_GATEWAY_MODEL` to `CF_AI_GATEWAY_MODELS` to allow a comma-separated list of models, with the first model set as the primary. Updated documentation, scripts, and tests to reflect this change while maintaining backward compatibility with the singular model variable. Adjusted related configurations and environment variable handling throughout the codebase. --- .dev.vars.example | 2 +- .github/workflows/test.yml | 4 +- README.md | 17 ++-- src/gateway/env.test.ts | 12 ++- src/gateway/env.ts | 3 +- src/types.ts | 3 +- start-openclaw.sh | 144 ++++++++++++++++++++++++--------- test/e2e/fixture/server/deploy | 5 +- 8 files changed, 138 insertions(+), 52 deletions(-) diff --git a/.dev.vars.example b/.dev.vars.example index 5fc7dca6f..5d26dd05f 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -9,7 +9,7 @@ ANTHROPIC_API_KEY=sk-ant-... # CLOUDFLARE_AI_GATEWAY_API_KEY=your-provider-api-key # CF_AI_GATEWAY_ACCOUNT_ID=your-account-id # CF_AI_GATEWAY_GATEWAY_ID=your-gateway-id -# CF_AI_GATEWAY_MODEL=workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast +# CF_AI_GATEWAY_MODELS=workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast # Legacy AI Gateway (still supported) # AI_GATEWAY_API_KEY=your-key diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69a814a94..04aa11625 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,7 +58,7 @@ jobs: DISCORD_DM_POLICY: "pairing" - name: workers-ai env: - CF_AI_GATEWAY_MODEL: "workers-ai/@cf/openai/gpt-oss-120b" + CF_AI_GATEWAY_MODELS: "workers-ai/@cf/openai/gpt-oss-120b" name: e2e (${{ matrix.config.name }}) @@ -115,7 +115,7 @@ jobs: TELEGRAM_DM_POLICY: ${{ matrix.config.env.TELEGRAM_DM_POLICY }} DISCORD_BOT_TOKEN: ${{ matrix.config.env.DISCORD_BOT_TOKEN }} DISCORD_DM_POLICY: ${{ matrix.config.env.DISCORD_DM_POLICY }} - CF_AI_GATEWAY_MODEL: ${{ matrix.config.env.CF_AI_GATEWAY_MODEL }} + CF_AI_GATEWAY_MODELS: ${{ matrix.config.env.CF_AI_GATEWAY_MODELS }} run: cctr -vv test/e2e - name: Convert video and generate thumbnail diff --git a/README.md b/README.md index ea82f03af..4a6f30891 100644 --- a/README.md +++ b/README.md @@ -382,23 +382,26 @@ When Cloudflare AI Gateway is configured, it takes precedence over direct `ANTHR ### Choosing a Model -By default, AI Gateway uses Anthropic's Claude Sonnet 4.5. To use a different model or provider, set `CF_AI_GATEWAY_MODEL` with the format `provider/model-id`: +By default, AI Gateway uses Anthropic's Claude Sonnet 4.5. To use a different model or provider, set `CF_AI_GATEWAY_MODELS` with a comma-separated list of `provider/model-id` values. The first model becomes the primary default: ```bash -npx wrangler secret put CF_AI_GATEWAY_MODEL -# Enter: workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast +npx wrangler secret put CF_AI_GATEWAY_MODELS +# Enter: google-ai-studio/gemini-3-flash-preview,anthropic/claude-sonnet-4-5,openai/gpt-4o ``` This works with any [AI Gateway provider](https://developers.cloudflare.com/ai-gateway/usage/providers/): -| Provider | Example `CF_AI_GATEWAY_MODEL` value | API key is... | -|----------|-------------------------------------|---------------| +| Provider | Example value | API key is... | +|----------|---------------|---------------| | Workers AI | `workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast` | Cloudflare API token | | OpenAI | `openai/gpt-4o` | OpenAI API key | | Anthropic | `anthropic/claude-sonnet-4-5` | Anthropic API key | +| Google AI Studio | `google-ai-studio/gemini-3-flash-preview` | Google AI API key | | Groq | `groq/llama-3.3-70b` | Groq API key | -**Note:** `CLOUDFLARE_AI_GATEWAY_API_KEY` must match the provider you're using — it's your provider's API key, forwarded through the gateway. You can only use one provider at a time through the gateway. For multiple providers, use direct keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) alongside the gateway config. +Anthropic and OpenAI use their native AI Gateway endpoints with full feature support. All other providers use the [unified OpenAI-compatible (compat) endpoint](https://developers.cloudflare.com/ai-gateway/usage/chat-completion/). + +**Note:** `CF_AI_GATEWAY_MODEL` (singular) is still supported for backward compatibility. #### Workers AI with Unified Billing @@ -415,7 +418,7 @@ The previous `AI_GATEWAY_API_KEY` + `AI_GATEWAY_BASE_URL` approach is still supp | `CLOUDFLARE_AI_GATEWAY_API_KEY` | Yes* | Your AI provider's API key, passed through the gateway (e.g., your Anthropic API key). Requires `CF_AI_GATEWAY_ACCOUNT_ID` and `CF_AI_GATEWAY_GATEWAY_ID` | | `CF_AI_GATEWAY_ACCOUNT_ID` | Yes* | Your Cloudflare account ID (used to construct the gateway URL) | | `CF_AI_GATEWAY_GATEWAY_ID` | Yes* | Your AI Gateway ID (used to construct the gateway URL) | -| `CF_AI_GATEWAY_MODEL` | No | Override default model: `provider/model-id` (e.g. `workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast`). See [Choosing a Model](#choosing-a-model) | +| `CF_AI_GATEWAY_MODELS` | No | Comma-separated `provider/model-id` list. First is primary. (e.g. `google-ai-studio/gemini-3-flash-preview,anthropic/claude-sonnet-4-5`). See [Choosing a Model](#choosing-a-model) | | `ANTHROPIC_API_KEY` | Yes* | Direct Anthropic API key (alternative to AI Gateway) | | `ANTHROPIC_BASE_URL` | No | Direct Anthropic API base URL | | `OPENAI_API_KEY` | No | OpenAI API key (alternative provider) | diff --git a/src/gateway/env.test.ts b/src/gateway/env.test.ts index 89af2efb8..1085759aa 100644 --- a/src/gateway/env.test.ts +++ b/src/gateway/env.test.ts @@ -130,12 +130,20 @@ describe('buildEnvVars', () => { }); // AI Gateway model override - it('passes CF_AI_GATEWAY_MODEL to container', () => { + it('passes CF_AI_GATEWAY_MODELS to container', () => { + const env = createMockEnv({ + CF_AI_GATEWAY_MODELS: 'google-ai-studio/gemini-3-flash-preview,anthropic/claude-sonnet-4-5', + }); + const result = buildEnvVars(env); + expect(result.CF_AI_GATEWAY_MODELS).toBe('google-ai-studio/gemini-3-flash-preview,anthropic/claude-sonnet-4-5'); + }); + + it('falls back from CF_AI_GATEWAY_MODEL to CF_AI_GATEWAY_MODELS', () => { const env = createMockEnv({ CF_AI_GATEWAY_MODEL: 'workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast', }); const result = buildEnvVars(env); - expect(result.CF_AI_GATEWAY_MODEL).toBe('workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast'); + expect(result.CF_AI_GATEWAY_MODELS).toBe('workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast'); }); it('passes CF_ACCOUNT_ID to container', () => { diff --git a/src/gateway/env.ts b/src/gateway/env.ts index 23dea539d..f98474d06 100644 --- a/src/gateway/env.ts +++ b/src/gateway/env.ts @@ -45,7 +45,8 @@ export function buildEnvVars(env: MoltbotEnv): Record { if (env.DISCORD_DM_POLICY) envVars.DISCORD_DM_POLICY = env.DISCORD_DM_POLICY; if (env.SLACK_BOT_TOKEN) envVars.SLACK_BOT_TOKEN = env.SLACK_BOT_TOKEN; if (env.SLACK_APP_TOKEN) envVars.SLACK_APP_TOKEN = env.SLACK_APP_TOKEN; - if (env.CF_AI_GATEWAY_MODEL) envVars.CF_AI_GATEWAY_MODEL = env.CF_AI_GATEWAY_MODEL; + const cfModels = env.CF_AI_GATEWAY_MODELS || env.CF_AI_GATEWAY_MODEL; + if (cfModels) envVars.CF_AI_GATEWAY_MODELS = cfModels; if (env.CF_ACCOUNT_ID) envVars.CF_ACCOUNT_ID = env.CF_ACCOUNT_ID; if (env.CDP_SECRET) envVars.CDP_SECRET = env.CDP_SECRET; if (env.WORKER_URL) envVars.WORKER_URL = env.WORKER_URL; diff --git a/src/types.ts b/src/types.ts index a85d32da3..5514acca1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,7 +11,8 @@ export interface MoltbotEnv { CF_AI_GATEWAY_ACCOUNT_ID?: string; // Cloudflare account ID for AI Gateway CF_AI_GATEWAY_GATEWAY_ID?: string; // AI Gateway ID CLOUDFLARE_AI_GATEWAY_API_KEY?: string; // API key for requests through the gateway - CF_AI_GATEWAY_MODEL?: string; // Override model: "provider/model-id" e.g. "workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast" + CF_AI_GATEWAY_MODELS?: string; // Override models: comma-separated "provider/model-id" list. First is primary. + CF_AI_GATEWAY_MODEL?: string; // Deprecated: use CF_AI_GATEWAY_MODELS (kept for backward compat) // Legacy AI Gateway configuration (still supported for backward compat) AI_GATEWAY_API_KEY?: string; // API key for the provider configured in AI Gateway AI_GATEWAY_BASE_URL?: string; // AI Gateway URL (e.g., https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/anthropic) diff --git a/start-openclaw.sh b/start-openclaw.sh index dd9381d12..ceeb201c9 100644 --- a/start-openclaw.sh +++ b/start-openclaw.sh @@ -194,48 +194,120 @@ if (process.env.OPENCLAW_DEV_MODE === 'true') { // so we don't need to patch the provider config. Writing a provider // entry without a models array breaks OpenClaw's config validation. -// AI Gateway model override (CF_AI_GATEWAY_MODEL=provider/model-id) -// Adds a provider entry for any AI Gateway provider and sets it as default model. +// AI Gateway model override (CF_AI_GATEWAY_MODELS=provider/model-id[,provider/model-id,...]) +// Comma-separated list of models. First model is the primary/default. +// Each provider gets its own config entry with the appropriate endpoint. +// CF_AI_GATEWAY_MODEL (singular) is supported for backward compatibility. // Examples: +// google-ai-studio/gemini-3-flash-preview,anthropic/claude-sonnet-4-5,openai/gpt-4o // workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast -// openai/gpt-4o -// anthropic/claude-sonnet-4-5 -if (process.env.CF_AI_GATEWAY_MODEL) { - const raw = process.env.CF_AI_GATEWAY_MODEL; - const slashIdx = raw.indexOf('/'); - const gwProvider = raw.substring(0, slashIdx); - const modelId = raw.substring(slashIdx + 1); - +const cfAiGatewayModels = process.env.CF_AI_GATEWAY_MODELS || process.env.CF_AI_GATEWAY_MODEL; +if (cfAiGatewayModels) { const accountId = process.env.CF_AI_GATEWAY_ACCOUNT_ID; const gatewayId = process.env.CF_AI_GATEWAY_GATEWAY_ID; const apiKey = process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; + const gwBase = (accountId && gatewayId) + ? 'https://gateway.ai.cloudflare.com/v1/' + accountId + '/' + gatewayId + : null; + + const entries = cfAiGatewayModels.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + + // Group models by provider + const providerGroups = {}; + entries.forEach(function(raw) { + const slashIdx = raw.indexOf('/'); + const gwProvider = raw.substring(0, slashIdx); + const modelId = raw.substring(slashIdx + 1); + if (!providerGroups[gwProvider]) providerGroups[gwProvider] = []; + providerGroups[gwProvider].push({ raw: raw, modelId: modelId }); + }); + + // Compat options for non-native providers (strips OpenAI-specific params) + const compatOptions = { + supportsStore: false, + supportsStrictMode: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + }; - let baseUrl; - if (accountId && gatewayId) { - baseUrl = 'https://gateway.ai.cloudflare.com/v1/' + accountId + '/' + gatewayId + '/' + gwProvider; - if (gwProvider === 'workers-ai') baseUrl += '/v1'; - } else if (gwProvider === 'workers-ai' && process.env.CF_ACCOUNT_ID) { - baseUrl = 'https://api.cloudflare.com/client/v4/accounts/' + process.env.CF_ACCOUNT_ID + '/ai/v1'; - } - - if (baseUrl && apiKey) { - const api = gwProvider === 'anthropic' ? 'anthropic-messages' : 'openai-completions'; - const providerName = 'cf-ai-gw-' + gwProvider; - - config.models = config.models || {}; - config.models.providers = config.models.providers || {}; - config.models.providers[providerName] = { - baseUrl: baseUrl, - apiKey: apiKey, - api: api, - models: [{ id: modelId, name: modelId, contextWindow: 131072, maxTokens: 8192 }], - }; - config.agents = config.agents || {}; - config.agents.defaults = config.agents.defaults || {}; - config.agents.defaults.model = { primary: providerName + '/' + modelId }; - console.log('AI Gateway model override: provider=' + providerName + ' model=' + modelId + ' via ' + baseUrl); - } else { - console.warn('CF_AI_GATEWAY_MODEL set but missing required config (account ID, gateway ID, or API key)'); + let primarySet = false; + config.models = config.models || {}; + config.models.providers = config.models.providers || {}; + + Object.keys(providerGroups).forEach(function(gwProvider) { + const models = providerGroups[gwProvider]; + let baseUrl, api, providerName, useCompat; + + if (gwBase) { + if (gwProvider === 'anthropic') { + // Anthropic: native endpoint with full feature support + baseUrl = gwBase + '/anthropic'; + api = 'anthropic-messages'; + providerName = 'cf-ai-gw-anthropic'; + useCompat = false; + } else if (gwProvider === 'openai') { + // OpenAI: native endpoint + baseUrl = gwBase + '/openai'; + api = 'openai-completions'; + providerName = 'cf-ai-gw-openai'; + useCompat = false; + } else { + // All other providers: unified OpenAI-compatible (compat) endpoint + // Docs: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/ + baseUrl = gwBase + '/compat'; + api = 'openai-completions'; + providerName = 'cf-ai-gw-compat'; + useCompat = true; + } + } else if (gwProvider === 'workers-ai' && process.env.CF_ACCOUNT_ID) { + // Fallback: direct Workers AI API without AI Gateway + baseUrl = 'https://api.cloudflare.com/client/v4/accounts/' + process.env.CF_ACCOUNT_ID + '/ai/v1'; + api = 'openai-completions'; + providerName = 'cf-ai-gw-workers-ai'; + useCompat = false; + } + + if (!baseUrl || !apiKey) { + console.warn('Skipping ' + gwProvider + ': missing base URL or API key'); + return; + } + + // Build model entries for this provider + const modelEntries = models.map(function(m) { + const effectiveId = useCompat ? m.raw : m.modelId; + const entry = { id: effectiveId, name: m.modelId, contextWindow: 131072, maxTokens: 8192 }; + if (useCompat) entry.compat = compatOptions; + return entry; + }); + + // Merge models if provider already exists (e.g. multiple compat providers) + if (config.models.providers[providerName]) { + config.models.providers[providerName].models = config.models.providers[providerName].models.concat(modelEntries); + } else { + config.models.providers[providerName] = { + baseUrl: baseUrl, + apiKey: apiKey, + api: api, + models: modelEntries, + }; + } + + // First model in the list becomes the primary default + if (!primarySet) { + const firstId = useCompat ? models[0].raw : models[0].modelId; + config.agents = config.agents || {}; + config.agents.defaults = config.agents.defaults || {}; + config.agents.defaults.model = { primary: providerName + '/' + firstId }; + primarySet = true; + } + + modelEntries.forEach(function(e) { + console.log('AI Gateway model: ' + providerName + '/' + e.id + ' via ' + baseUrl); + }); + }); + + if (!primarySet) { + console.warn('CF_AI_GATEWAY_MODELS set but no models configured (missing account ID, gateway ID, or API key)'); } } diff --git a/test/e2e/fixture/server/deploy b/test/e2e/fixture/server/deploy index 8c18666fd..9ac3855e6 100755 --- a/test/e2e/fixture/server/deploy +++ b/test/e2e/fixture/server/deploy @@ -80,8 +80,9 @@ fi if [ -n "$CF_AI_GATEWAY_GATEWAY_ID" ]; then echo "$CF_AI_GATEWAY_GATEWAY_ID" | npx wrangler secret put CF_AI_GATEWAY_GATEWAY_ID --name "$WORKER_NAME" >&2 fi -if [ -n "$CF_AI_GATEWAY_MODEL" ]; then - echo "$CF_AI_GATEWAY_MODEL" | npx wrangler secret put CF_AI_GATEWAY_MODEL --name "$WORKER_NAME" >&2 +CF_MODELS_VALUE="${CF_AI_GATEWAY_MODELS:-$CF_AI_GATEWAY_MODEL}" +if [ -n "$CF_MODELS_VALUE" ]; then + echo "$CF_MODELS_VALUE" | npx wrangler secret put CF_AI_GATEWAY_MODELS --name "$WORKER_NAME" >&2 fi # Legacy AI Gateway (still supported) if [ -n "$AI_GATEWAY_API_KEY" ]; then From e91950d9e201243fe3a138381addeb8f201ab564 Mon Sep 17 00:00:00 2001 From: ebar0n Date: Mon, 9 Feb 2026 22:52:51 -0500 Subject: [PATCH 2/2] fix: update AI provider configuration hints in error handling Refactored the error handling logic to check for multiple AI provider environment variables. Updated the hint message to guide users on setting the appropriate API keys when no AI provider is configured, improving clarity and usability. --- src/index.ts | 5 +++-- start-openclaw.sh | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 53b06d3d2..e056f98a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -263,8 +263,9 @@ app.all('*', async (c) => { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; let hint = 'Check worker logs with: wrangler tail'; - if (!c.env.ANTHROPIC_API_KEY) { - hint = 'ANTHROPIC_API_KEY is not set. Run: wrangler secret put ANTHROPIC_API_KEY'; + const hasAiProvider = c.env.ANTHROPIC_API_KEY || c.env.OPENAI_API_KEY || c.env.CLOUDFLARE_AI_GATEWAY_API_KEY || c.env.AI_GATEWAY_API_KEY; + if (!hasAiProvider) { + hint = 'No AI provider configured. Set CLOUDFLARE_AI_GATEWAY_API_KEY (with CF_AI_GATEWAY_ACCOUNT_ID + CF_AI_GATEWAY_GATEWAY_ID), or ANTHROPIC_API_KEY, or OPENAI_API_KEY'; } else if (errorMessage.includes('heap out of memory') || errorMessage.includes('OOM')) { hint = 'Gateway ran out of memory. Try again or check for memory leaks.'; } diff --git a/start-openclaw.sh b/start-openclaw.sh index ceeb201c9..29ccc6f7a 100644 --- a/start-openclaw.sh +++ b/start-openclaw.sh @@ -225,7 +225,6 @@ if (cfAiGatewayModels) { // Compat options for non-native providers (strips OpenAI-specific params) const compatOptions = { supportsStore: false, - supportsStrictMode: false, supportsDeveloperRole: false, supportsReasoningEffort: false, };