diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e1999b3d..1fb669c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,8 +40,15 @@ jobs: fail-fast: false matrix: config: - - name: base + - name: aig-byok env: {} + - name: anthropic-direct + env: + USE_ANTHROPIC_DIRECT: "true" + - name: aig-authenticated + env: + USE_ANTHROPIC_DIRECT: "true" + USE_AI_GATEWAY: "true" - name: telegram env: TELEGRAM_BOT_TOKEN: "fake-telegram-bot-token-for-e2e" @@ -80,8 +87,12 @@ jobs: id: e2e continue-on-error: true env: - AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} - AI_GATEWAY_BASE_URL: ${{ secrets.AI_GATEWAY_BASE_URL }} + # aig-byok uses AI Gateway with BYOK/Unified Billing (no direct key) + # anthropic-direct uses ANTHROPIC_API_KEY only (no gateway) + # aig-authenticated uses both (authenticated gateway + direct key) + AI_GATEWAY_API_KEY: ${{ (matrix.config.env.USE_ANTHROPIC_DIRECT != 'true' || matrix.config.env.USE_AI_GATEWAY == 'true') && secrets.AI_GATEWAY_API_KEY || '' }} + AI_GATEWAY_BASE_URL: ${{ (matrix.config.env.USE_ANTHROPIC_DIRECT != 'true' || matrix.config.env.USE_AI_GATEWAY == 'true') && secrets.AI_GATEWAY_BASE_URL || '' }} + ANTHROPIC_API_KEY: ${{ matrix.config.env.USE_ANTHROPIC_DIRECT == 'true' && secrets.ANTHROPIC_API_KEY || '' }} TELEGRAM_BOT_TOKEN: ${{ matrix.config.env.TELEGRAM_BOT_TOKEN }} TELEGRAM_DM_POLICY: ${{ matrix.config.env.TELEGRAM_DM_POLICY }} DISCORD_BOT_TOKEN: ${{ matrix.config.env.DISCORD_BOT_TOKEN }} diff --git a/README.md b/README.md index 90bf7b72..c55558d3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Run [OpenClaw](https://github.com/openclaw/openclaw) (formerly Moltbot, formerly ## Requirements - [Workers Paid plan](https://www.cloudflare.com/plans/developer-platform/) ($5 USD/month) — required for Cloudflare Sandbox containers -- [Anthropic API key](https://console.anthropic.com/) — for Claude access, or you can use AI Gateway's [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/) +- [Anthropic API key/Claude Code Subscription](https://console.anthropic.com/) — for Claude access, or you can use AI Gateway's [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/) The following Cloudflare features used by this project have free tiers: - Cloudflare Access (authentication) @@ -46,6 +46,9 @@ npm install # Set your API key (direct Anthropic access) npx wrangler secret put ANTHROPIC_API_KEY +# Or set your Claude Code token (claude setup-token) +# npx wrangler secret put ANTHROPIC_OAUTH_TOKEN + # Or use AI Gateway instead (see "Optional: Cloudflare AI Gateway" below) # npx wrangler secret put AI_GATEWAY_API_KEY # npx wrangler secret put AI_GATEWAY_BASE_URL diff --git a/src/gateway/env.test.ts b/src/gateway/env.test.ts index 29f033db..87b1a241 100644 --- a/src/gateway/env.test.ts +++ b/src/gateway/env.test.ts @@ -45,30 +45,6 @@ describe('buildEnvVars', () => { expect(result.AI_GATEWAY_BASE_URL).toBe('https://gateway.ai.cloudflare.com/v1/123/my-gw/anthropic'); }); - it('AI_GATEWAY_* takes precedence over direct provider keys for Anthropic', () => { - const env = createMockEnv({ - AI_GATEWAY_API_KEY: 'gateway-key', - AI_GATEWAY_BASE_URL: 'https://gateway.example.com/anthropic', - ANTHROPIC_API_KEY: 'direct-key', - ANTHROPIC_BASE_URL: 'https://api.anthropic.com', - }); - const result = buildEnvVars(env); - expect(result.ANTHROPIC_API_KEY).toBe('gateway-key'); - expect(result.AI_GATEWAY_BASE_URL).toBe('https://gateway.example.com/anthropic'); - }); - - it('AI_GATEWAY_* takes precedence over direct provider keys for OpenAI', () => { - const env = createMockEnv({ - AI_GATEWAY_API_KEY: 'gateway-key', - AI_GATEWAY_BASE_URL: 'https://gateway.example.com/openai', - OPENAI_API_KEY: 'direct-key', - }); - const result = buildEnvVars(env); - expect(result.OPENAI_API_KEY).toBe('gateway-key'); - expect(result.AI_GATEWAY_BASE_URL).toBe('https://gateway.example.com/openai'); - expect(result.OPENAI_BASE_URL).toBe('https://gateway.example.com/openai'); - }); - it('falls back to ANTHROPIC_* when AI_GATEWAY_* not set', () => { const env = createMockEnv({ ANTHROPIC_API_KEY: 'direct-key', diff --git a/src/gateway/env.ts b/src/gateway/env.ts index a57e781b..a155f7e9 100644 --- a/src/gateway/env.ts +++ b/src/gateway/env.ts @@ -13,23 +13,31 @@ export function buildEnvVars(env: MoltbotEnv): Record { const normalizedBaseUrl = env.AI_GATEWAY_BASE_URL?.replace(/\/+$/, ''); const isOpenAIGateway = normalizedBaseUrl?.endsWith('/openai'); - // AI Gateway vars take precedence - // Map to the appropriate provider env var based on the gateway endpoint + // If key in request (user provided ANTHROPIC_API_KEY) pass ANTHROPIC_API_KEY as is + if (env.ANTHROPIC_API_KEY) { + envVars.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY; + } + if (env.ANTHROPIC_OAUTH_TOKEN) { + envVars.ANTHROPIC_OAUTH_TOKEN = env.ANTHROPIC_OAUTH_TOKEN; + } + if (env.OPENAI_API_KEY) { + envVars.OPENAI_API_KEY = env.OPENAI_API_KEY; + } + + // AI Gateway will take use auth token from either provider specific headers (x-api-key, Authorization) or cf-aig-authorization header + // If the user wants to use AI Gateway (authenticated) + // 1. If Anthropic/OpenAI key is not passed directly (stored with BYOK or if Unified Billing is used), pass AI_GATEWAY_API_KEY in vendor specific header + // 2. If key is passed directly pass AI_GATEWAY_API_KEY in cf-aig-authorization header if (env.AI_GATEWAY_API_KEY) { - if (isOpenAIGateway) { + if (isOpenAIGateway && !envVars.OPENAI_API_KEY) { envVars.OPENAI_API_KEY = env.AI_GATEWAY_API_KEY; - } else { + } else if (!envVars.ANTHROPIC_API_KEY && !envVars.ANTHROPIC_OAUTH_TOKEN) { envVars.ANTHROPIC_API_KEY = env.AI_GATEWAY_API_KEY; + } else { + envVars.AI_GATEWAY_API_KEY = env.AI_GATEWAY_API_KEY; } } - // Fall back to direct provider keys - if (!envVars.ANTHROPIC_API_KEY && env.ANTHROPIC_API_KEY) { - envVars.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY; - } - if (!envVars.OPENAI_API_KEY && env.OPENAI_API_KEY) { - envVars.OPENAI_API_KEY = env.OPENAI_API_KEY; - } // Pass base URL (used by start-moltbot.sh to determine provider) if (normalizedBaseUrl) { @@ -58,3 +66,4 @@ export function buildEnvVars(env: MoltbotEnv): Record { return envVars; } + diff --git a/src/index.ts b/src/index.ts index ed08910c..f291e35a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,9 +78,9 @@ function validateRequiredEnv(env: MoltbotEnv): string[] { if (!env.AI_GATEWAY_BASE_URL) { missing.push('AI_GATEWAY_BASE_URL (required when using AI_GATEWAY_API_KEY)'); } - } else if (!env.ANTHROPIC_API_KEY) { - // Direct Anthropic access requires API key - missing.push('ANTHROPIC_API_KEY or AI_GATEWAY_API_KEY'); + } else if (!env.ANTHROPIC_API_KEY && !env.ANTHROPIC_OAUTH_TOKEN) { + // Direct Anthropic access requires API key or OAuth token + missing.push('ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN or AI_GATEWAY_API_KEY'); } return missing; diff --git a/src/types.ts b/src/types.ts index 6287bc70..0d6d7802 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ export interface MoltbotEnv { AI_GATEWAY_BASE_URL?: string; // AI Gateway URL (e.g., https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/anthropic) // Legacy direct provider configuration (fallback) ANTHROPIC_API_KEY?: string; + ANTHROPIC_OAUTH_TOKEN?: string; ANTHROPIC_BASE_URL?: string; OPENAI_API_KEY?: string; MOLTBOT_GATEWAY_TOKEN?: string; // Gateway token (mapped to CLAWDBOT_GATEWAY_TOKEN for container) diff --git a/start-moltbot.sh b/start-moltbot.sh index 286a4d67..79d40970 100644 --- a/start-moltbot.sh +++ b/start-moltbot.sh @@ -229,6 +229,12 @@ if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) { const baseUrl = (process.env.AI_GATEWAY_BASE_URL || process.env.ANTHROPIC_BASE_URL || '').replace(/\/+$/, ''); const isOpenAI = baseUrl.endsWith('/openai'); +const headers = {}; + +if(process.env.AI_GATEWAY_API_KEY) { + headers['cf-aig-authorization'] = 'Bearer ' + process.env.AI_GATEWAY_API_KEY; +} + if (isOpenAI) { // Create custom openai provider config with baseUrl override // Omit apiKey so moltbot falls back to OPENAI_API_KEY env var @@ -238,6 +244,7 @@ if (isOpenAI) { config.models.providers.openai = { baseUrl: baseUrl, api: 'openai-responses', + headers, models: [ { id: 'gpt-5.2', name: 'GPT-5.2', contextWindow: 200000 }, { id: 'gpt-5', name: 'GPT-5', contextWindow: 200000 }, @@ -257,6 +264,7 @@ if (isOpenAI) { const providerConfig = { baseUrl: baseUrl, api: 'anthropic-messages', + headers, models: [ { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', contextWindow: 200000 }, { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', contextWindow: 200000 }, @@ -266,6 +274,9 @@ if (isOpenAI) { // Include API key in provider config if set (required when using custom baseUrl) if (process.env.ANTHROPIC_API_KEY) { providerConfig.apiKey = process.env.ANTHROPIC_API_KEY; + } else if (process.env.ANTHROPIC_OAUTH_TOKEN) { + providerConfig.auth = "token"; + providerConfig.apiKey = process.env.ANTHROPIC_OAUTH_TOKEN; } config.models.providers.anthropic = providerConfig; // Add models to the allowlist so they appear in /models diff --git a/test/e2e/fixture/start-server b/test/e2e/fixture/start-server index 8e28a1d6..b13a93c9 100755 --- a/test/e2e/fixture/start-server +++ b/test/e2e/fixture/start-server @@ -80,7 +80,7 @@ if [ -f "$PROJECT_DIR/.dev.vars" ]; then fi # Also pick up API keys and channel tokens from environment (for CI) -for var in AI_GATEWAY_API_KEY AI_GATEWAY_BASE_URL ANTHROPIC_API_KEY OPENAI_API_KEY \ +for var in AI_GATEWAY_API_KEY AI_GATEWAY_BASE_URL ANTHROPIC_API_KEY ANTHROPIC_OAUTH_TOKEN OPENAI_API_KEY \ TELEGRAM_BOT_TOKEN TELEGRAM_DM_POLICY TELEGRAM_DM_ALLOW_FROM \ DISCORD_BOT_TOKEN DISCORD_DM_POLICY \ SLACK_BOT_TOKEN SLACK_APP_TOKEN; do