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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }})

Expand Down Expand Up @@ -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
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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) |
Expand Down
12 changes: 10 additions & 2 deletions src/gateway/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/gateway/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export function buildEnvVars(env: MoltbotEnv): Record<string, string> {
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;
Expand Down
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
}
Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
143 changes: 107 additions & 36 deletions start-openclaw.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
}
}

Expand Down
5 changes: 3 additions & 2 deletions test/e2e/fixture/server/deploy
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down