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/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/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..29ccc6f7a 100644 --- a/start-openclaw.sh +++ b/start-openclaw.sh @@ -194,48 +194,119 @@ 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, + 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