From 8434d24da55ca26cdb9fe7d2f43b63bb38380e18 Mon Sep 17 00:00:00 2001 From: 0xCAB0 Date: Sat, 21 Feb 2026 19:04:08 +0100 Subject: [PATCH 1/2] refactor: abstract AI Gateway provider to ModelApi mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded ternary with extensible apiMap object that maps Cloudflare AI Gateway provider names to OpenClaw ModelApi types. Supported mappings: - anthropic → anthropic-messages - google-ai-studio → google-generative-ai - bedrock → bedrock-converse-stream - workers-ai → parses @cf// to select API: - @cf/meta/* (LLaMA) → ollama - @cf/openai/*, @cf/mistral/*, etc. → openai-completions - All others default to openai-completions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- start-openclaw.sh | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/start-openclaw.sh b/start-openclaw.sh index c862a80ce..278d5c07c 100644 --- a/start-openclaw.sh +++ b/start-openclaw.sh @@ -199,7 +199,28 @@ if (process.env.CF_AI_GATEWAY_MODEL) { } if (baseUrl && apiKey) { - const api = gwProvider === 'anthropic' ? 'anthropic-messages' : 'openai-completions'; + // Map Cloudflare AI Gateway provider to OpenClaw ModelApi type + // CF providers: https://developers.cloudflare.com/ai-gateway/usage/providers + // OpenClaw API types: https://github.com/openclaw/openclaw/blob/main/src/config/types.models.ts + const apiMap = { + 'anthropic': 'anthropic-messages', + 'google-ai-studio': 'google-generative-ai', + 'bedrock': 'bedrock-converse-stream', + // openai, groq, mistral, openrouter, etc. use openai-completions + }; + let api = apiMap[gwProvider] || 'openai-completions'; + + // workers-ai: parse @cf// to select API based on vendor + if (gwProvider === 'workers-ai') { + const vendorMatch = modelId.match(/^@cf\/([^/]+)\//); + if (vendorMatch) { + const vendor = vendorMatch[1]; + if (vendor === 'meta') { + api = 'ollama'; // LLaMA models use ollama API + } + // openai, mistral, etc. stay as openai-completions + } + } const providerName = 'cf-ai-gw-' + gwProvider; config.models = config.models || {}; From 6aad07c90fde4cb21753fbd19f4bbf7746a6049b Mon Sep 17 00:00:00 2001 From: 0xCAB0 Date: Sat, 21 Feb 2026 19:17:33 +0100 Subject: [PATCH 2/2] Added test suite --- src/ai-gateway-mapping.test.ts | 333 +++++++++++++++++++++++++++++++++ start-openclaw.sh | 0 2 files changed, 333 insertions(+) create mode 100644 src/ai-gateway-mapping.test.ts mode change 100644 => 100755 start-openclaw.sh diff --git a/src/ai-gateway-mapping.test.ts b/src/ai-gateway-mapping.test.ts new file mode 100644 index 000000000..ba05c89f9 --- /dev/null +++ b/src/ai-gateway-mapping.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect } from 'vitest'; + +/** + * AI Gateway provider to OpenClaw ModelApi mapping logic + * Extracted from start-openclaw.sh for testing + */ +function getModelApi(gwProvider: string, modelId: string): string { + const apiMap: Record = { + anthropic: 'anthropic-messages', + 'google-ai-studio': 'google-generative-ai', + bedrock: 'bedrock-converse-stream', + }; + let api = apiMap[gwProvider] || 'openai-completions'; + + // workers-ai: parse @cf// to select API based on vendor + if (gwProvider === 'workers-ai') { + const vendorMatch = modelId.match(/^@cf\/([^/]+)\//); + if (vendorMatch) { + const vendor = vendorMatch[1]; + if (vendor === 'meta') { + api = 'ollama'; // LLaMA models use ollama API + } + // openai, mistral, etc. stay as openai-completions + } + } + + return api; +} + +/** + * Parse CF_AI_GATEWAY_MODEL into provider and model ID + */ +function parseGatewayModel(raw: string): { gwProvider: string; modelId: string } { + const slashIdx = raw.indexOf('/'); + return { + gwProvider: raw.substring(0, slashIdx), + modelId: raw.substring(slashIdx + 1), + }; +} + +describe('AI Gateway Provider to ModelApi Mapping', () => { + describe('parseGatewayModel', () => { + it('parses provider/model format', () => { + const result = parseGatewayModel('anthropic/claude-sonnet-4-5'); + expect(result.gwProvider).toBe('anthropic'); + expect(result.modelId).toBe('claude-sonnet-4-5'); + }); + + it('handles workers-ai with nested path', () => { + const result = parseGatewayModel('workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast'); + expect(result.gwProvider).toBe('workers-ai'); + expect(result.modelId).toBe('@cf/meta/llama-3.3-70b-instruct-fp8-fast'); + }); + }); + + describe('Cloudflare AI Gateway Providers', () => { + // https://developers.cloudflare.com/ai-gateway/usage/providers + + describe('anthropic', () => { + it('maps to anthropic-messages', () => { + expect(getModelApi('anthropic', 'claude-sonnet-4-5')).toBe('anthropic-messages'); + expect(getModelApi('anthropic', 'claude-3-5-haiku-latest')).toBe('anthropic-messages'); + expect(getModelApi('anthropic', 'claude-3-opus-20240229')).toBe('anthropic-messages'); + }); + }); + + describe('openai', () => { + it('maps to openai-completions', () => { + expect(getModelApi('openai', 'gpt-4o')).toBe('openai-completions'); + expect(getModelApi('openai', 'gpt-4-turbo')).toBe('openai-completions'); + expect(getModelApi('openai', 'gpt-3.5-turbo')).toBe('openai-completions'); + expect(getModelApi('openai', 'o1-preview')).toBe('openai-completions'); + }); + }); + + describe('azure-openai', () => { + it('maps to openai-completions', () => { + expect(getModelApi('azure-openai', 'gpt-4o-deployment')).toBe('openai-completions'); + expect(getModelApi('azure-openai', 'gpt-35-turbo')).toBe('openai-completions'); + }); + }); + + describe('google-ai-studio', () => { + it('maps to google-generative-ai', () => { + expect(getModelApi('google-ai-studio', 'gemini-1.5-pro')).toBe('google-generative-ai'); + expect(getModelApi('google-ai-studio', 'gemini-1.5-flash')).toBe('google-generative-ai'); + expect(getModelApi('google-ai-studio', 'gemini-2.0-flash')).toBe('google-generative-ai'); + }); + }); + + describe('google-vertex-ai', () => { + it('maps to openai-completions (uses OpenAI-compatible endpoint)', () => { + expect(getModelApi('google-vertex-ai', 'gemini-1.5-pro')).toBe('openai-completions'); + }); + }); + + describe('bedrock', () => { + it('maps to bedrock-converse-stream', () => { + expect(getModelApi('bedrock', 'anthropic.claude-3-sonnet-20240229-v1:0')).toBe( + 'bedrock-converse-stream', + ); + expect(getModelApi('bedrock', 'amazon.titan-text-express-v1')).toBe('bedrock-converse-stream'); + expect(getModelApi('bedrock', 'meta.llama3-70b-instruct-v1:0')).toBe('bedrock-converse-stream'); + }); + }); + + describe('workers-ai', () => { + describe('meta models (LLaMA)', () => { + it('maps to ollama API', () => { + expect(getModelApi('workers-ai', '@cf/meta/llama-3.3-70b-instruct-fp8-fast')).toBe('ollama'); + expect(getModelApi('workers-ai', '@cf/meta/llama-3.1-8b-instruct')).toBe('ollama'); + expect(getModelApi('workers-ai', '@cf/meta/llama-3.2-3b-instruct')).toBe('ollama'); + expect(getModelApi('workers-ai', '@cf/meta/llama-3-8b-instruct')).toBe('ollama'); + expect(getModelApi('workers-ai', '@cf/meta/llama-2-7b-chat-fp16')).toBe('ollama'); + }); + }); + + describe('openai models', () => { + it('maps to openai-completions', () => { + expect(getModelApi('workers-ai', '@cf/openai/whisper')).toBe('openai-completions'); + }); + }); + + describe('mistral models', () => { + it('maps to openai-completions', () => { + expect(getModelApi('workers-ai', '@cf/mistral/mistral-7b-instruct-v0.1')).toBe( + 'openai-completions', + ); + expect(getModelApi('workers-ai', '@cf/mistral/mistral-7b-instruct-v0.2-lora')).toBe( + 'openai-completions', + ); + }); + }); + + describe('qwen models', () => { + it('maps to openai-completions', () => { + expect(getModelApi('workers-ai', '@cf/qwen/qwen1.5-14b-chat-awq')).toBe('openai-completions'); + expect(getModelApi('workers-ai', '@cf/qwen/qwen1.5-7b-chat-awq')).toBe('openai-completions'); + }); + }); + + describe('deepseek models', () => { + it('maps to openai-completions', () => { + expect(getModelApi('workers-ai', '@cf/deepseek-ai/deepseek-math-7b-instruct')).toBe( + 'openai-completions', + ); + }); + }); + + describe('google models', () => { + it('maps to openai-completions', () => { + expect(getModelApi('workers-ai', '@cf/google/gemma-7b-it-lora')).toBe('openai-completions'); + expect(getModelApi('workers-ai', '@cf/google/gemma-2b-it-lora')).toBe('openai-completions'); + }); + }); + + describe('microsoft models', () => { + it('maps to openai-completions', () => { + expect(getModelApi('workers-ai', '@cf/microsoft/phi-2')).toBe('openai-completions'); + }); + }); + + describe('tinyllama models', () => { + it('maps to openai-completions', () => { + expect(getModelApi('workers-ai', '@cf/tinyllama/tinyllama-1.1b-chat-v1.0')).toBe( + 'openai-completions', + ); + }); + }); + + describe('thebloke models', () => { + it('maps to openai-completions', () => { + expect(getModelApi('workers-ai', '@cf/thebloke/discolm-german-7b-v1-awq')).toBe( + 'openai-completions', + ); + }); + }); + + describe('defog models', () => { + it('maps to openai-completions', () => { + expect(getModelApi('workers-ai', '@cf/defog/sqlcoder-7b-2')).toBe('openai-completions'); + }); + }); + + describe('nexusflow models', () => { + it('maps to openai-completions', () => { + expect(getModelApi('workers-ai', '@cf/nexusflow/starling-lm-7b-beta')).toBe( + 'openai-completions', + ); + }); + }); + + describe('non-@cf format (fallback)', () => { + it('maps to openai-completions', () => { + expect(getModelApi('workers-ai', 'some-other-model')).toBe('openai-completions'); + }); + }); + }); + + describe('groq', () => { + it('maps to openai-completions', () => { + expect(getModelApi('groq', 'llama-3.3-70b-versatile')).toBe('openai-completions'); + expect(getModelApi('groq', 'mixtral-8x7b-32768')).toBe('openai-completions'); + expect(getModelApi('groq', 'gemma2-9b-it')).toBe('openai-completions'); + }); + }); + + describe('mistral', () => { + it('maps to openai-completions', () => { + expect(getModelApi('mistral', 'mistral-large-latest')).toBe('openai-completions'); + expect(getModelApi('mistral', 'mistral-medium-latest')).toBe('openai-completions'); + expect(getModelApi('mistral', 'mistral-small-latest')).toBe('openai-completions'); + expect(getModelApi('mistral', 'codestral-latest')).toBe('openai-completions'); + }); + }); + + describe('cohere', () => { + it('maps to openai-completions', () => { + expect(getModelApi('cohere', 'command-r-plus')).toBe('openai-completions'); + expect(getModelApi('cohere', 'command-r')).toBe('openai-completions'); + expect(getModelApi('cohere', 'command-light')).toBe('openai-completions'); + }); + }); + + describe('deepseek', () => { + it('maps to openai-completions', () => { + expect(getModelApi('deepseek', 'deepseek-chat')).toBe('openai-completions'); + expect(getModelApi('deepseek', 'deepseek-coder')).toBe('openai-completions'); + expect(getModelApi('deepseek', 'deepseek-reasoner')).toBe('openai-completions'); + }); + }); + + describe('perplexity', () => { + it('maps to openai-completions', () => { + expect(getModelApi('perplexity', 'llama-3.1-sonar-large-128k-online')).toBe( + 'openai-completions', + ); + expect(getModelApi('perplexity', 'llama-3.1-sonar-small-128k-chat')).toBe('openai-completions'); + }); + }); + + describe('openrouter', () => { + it('maps to openai-completions', () => { + expect(getModelApi('openrouter', 'anthropic/claude-3-opus')).toBe('openai-completions'); + expect(getModelApi('openrouter', 'openai/gpt-4-turbo')).toBe('openai-completions'); + expect(getModelApi('openrouter', 'google/gemini-pro')).toBe('openai-completions'); + }); + }); + + describe('huggingface', () => { + it('maps to openai-completions', () => { + expect(getModelApi('huggingface', 'meta-llama/Meta-Llama-3-8B-Instruct')).toBe( + 'openai-completions', + ); + expect(getModelApi('huggingface', 'mistralai/Mistral-7B-Instruct-v0.2')).toBe( + 'openai-completions', + ); + }); + }); + + describe('replicate', () => { + it('maps to openai-completions', () => { + expect(getModelApi('replicate', 'meta/llama-2-70b-chat')).toBe('openai-completions'); + }); + }); + + describe('xai', () => { + it('maps to openai-completions', () => { + expect(getModelApi('xai', 'grok-beta')).toBe('openai-completions'); + expect(getModelApi('xai', 'grok-2')).toBe('openai-completions'); + }); + }); + + describe('cerebras', () => { + it('maps to openai-completions', () => { + expect(getModelApi('cerebras', 'llama3.1-8b')).toBe('openai-completions'); + expect(getModelApi('cerebras', 'llama3.1-70b')).toBe('openai-completions'); + }); + }); + + // Audio/Speech providers (not typically used for chat, but included for completeness) + describe('elevenlabs', () => { + it('maps to openai-completions (default)', () => { + expect(getModelApi('elevenlabs', 'eleven_multilingual_v2')).toBe('openai-completions'); + }); + }); + + describe('deepgram', () => { + it('maps to openai-completions (default)', () => { + expect(getModelApi('deepgram', 'nova-2')).toBe('openai-completions'); + }); + }); + + describe('cartesia', () => { + it('maps to openai-completions (default)', () => { + expect(getModelApi('cartesia', 'sonic-english')).toBe('openai-completions'); + }); + }); + + // Image generation providers + describe('fal-ai', () => { + it('maps to openai-completions (default)', () => { + expect(getModelApi('fal-ai', 'flux/dev')).toBe('openai-completions'); + }); + }); + + describe('ideogram', () => { + it('maps to openai-completions (default)', () => { + expect(getModelApi('ideogram', 'ideogram-v2')).toBe('openai-completions'); + }); + }); + + // Other providers + describe('baseten', () => { + it('maps to openai-completions (default)', () => { + expect(getModelApi('baseten', 'custom-model')).toBe('openai-completions'); + }); + }); + + describe('parallel', () => { + it('maps to openai-completions (default)', () => { + expect(getModelApi('parallel', 'parallel-model')).toBe('openai-completions'); + }); + }); + + describe('unknown providers', () => { + it('maps to openai-completions as default', () => { + expect(getModelApi('future-provider', 'some-model')).toBe('openai-completions'); + expect(getModelApi('custom', 'model-x')).toBe('openai-completions'); + }); + }); + }); +}); diff --git a/start-openclaw.sh b/start-openclaw.sh old mode 100644 new mode 100755