diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index a7006b2..3cb241e 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -2,3 +2,4 @@ - [Memory Saving Rules](memory_saving_rules.md) — How to save memories in this project - [Implementation Plan](implementation_plan.md) — Architecture and design decisions for Claude Central Gateway +- [Framework Decision - Hono](framework_decision.md) — Why Hono was chosen over alternatives diff --git a/.claude/memory/framework_decision.md b/.claude/memory/framework_decision.md new file mode 100644 index 0000000..261685a --- /dev/null +++ b/.claude/memory/framework_decision.md @@ -0,0 +1,26 @@ +--- +name: Framework Decision - Hono +description: Why Hono was chosen over alternatives for the gateway +type: project +--- + +## Framework Choice: Hono + +**Decision:** Use Hono as the web framework for Claude Central Gateway. + +**Why Hono over alternatives:** + +| Alternative | Why not | +|-------------|---------| +| Nitro | Overkill for simple proxy, 200KB+ bundle vs 14KB | +| itty-router | Cloudflare-focused, Vercel needs adapter | +| Native | Duplicate code per platform, manual streaming | + +**Why Hono:** +- Single codebase for Vercel + Cloudflare + Deno + Bun +- Ultra-lightweight (~14KB) +- First-class streaming support (critical for SSE) +- Zero-config multi-platform +- Aligns with project philosophy: "Minimal, simple, deploy anywhere" + +**How to apply:** All API routes should use Hono's `app.route()` pattern. Keep handlers simple and stateless. diff --git a/.claude/memory/implementation_plan.md b/.claude/memory/implementation_plan.md index ab4af31..3cb2811 100644 --- a/.claude/memory/implementation_plan.md +++ b/.claude/memory/implementation_plan.md @@ -21,7 +21,8 @@ Claude Code → Gateway (Vercel) → OpenAI API ### Key Decisions - **Language**: Node.js with JavaScript (no TypeScript) -- **Deployment**: Vercel serverless functions +- **Framework**: Hono (multi-platform: Vercel, Cloudflare, Deno, Bun) +- **Deployment**: Vercel serverless functions OR Cloudflare Workers - **Providers**: OpenAI first (via official SDK), others in TODO - **Config**: Environment variables only (no database) - **Auth**: Single shared token (user's `ANTHROPIC_AUTH_TOKEN` must match `GATEWAY_TOKEN`) @@ -39,9 +40,15 @@ Claude Code → Gateway (Vercel) → OpenAI API ### File Structure ``` -api/v1/messages.js - Main proxy handler -package.json - Dependencies (openai SDK) -vercel.json - Routing config +src/ +├── index.js - Hono app entry point +├── routes/ +│ └── messages.js - /v1/messages proxy handler +api/ +└── index.js - Vercel adapter +package.json - Dependencies (hono, openai) +vercel.json - Vercel config +wrangler.toml - Cloudflare Workers config ``` ### How to apply: When adding new providers or modifying the gateway, follow the established pattern in `api/v1/messages.js` for request/response transformation. diff --git a/README.md b/README.md index caaba6f..6ecc198 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Minimal, simple, deploy anywhere. ## Quick Start -### 1. Deploy to Vercel +### Deploy to Vercel [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/tiennm99/claude-central-gateway) @@ -21,12 +21,24 @@ Or manually: ```bash git clone https://github.com/tiennm99/claude-central-gateway cd claude-central-gateway +npm install vercel ``` -### 2. Set Environment Variables +### Deploy to Cloudflare Workers -In Vercel dashboard, set these environment variables: +```bash +git clone https://github.com/tiennm99/claude-central-gateway +cd claude-central-gateway +npm install +npm run deploy:cf +``` + +### Set Environment Variables + +**Vercel**: Dashboard → Settings → Environment Variables + +**Cloudflare**: `wrangler.toml` or Dashboard → Workers → Variables | Variable | Description | Example | |----------|-------------|---------| @@ -34,7 +46,7 @@ In Vercel dashboard, set these environment variables: | `OPENAI_API_KEY` | Your OpenAI API key | `sk-...` | | `MODEL_MAP` | Model name mapping | `claude-sonnet-4-20250514:gpt-4o` | -### 3. Configure Claude Code +### Configure Claude Code ```bash export ANTHROPIC_BASE_URL=https://your-gateway.vercel.app diff --git a/api/index.js b/api/index.js new file mode 100644 index 0000000..6de2fb3 --- /dev/null +++ b/api/index.js @@ -0,0 +1,3 @@ +import app from '../src/index.js'; + +export default app.fetch; diff --git a/api/v1/messages.js b/api/v1/messages.js deleted file mode 100644 index b081fbb..0000000 --- a/api/v1/messages.js +++ /dev/null @@ -1,226 +0,0 @@ -import OpenAI from 'openai'; - -// Parse model mapping from env var -function parseModelMap(envVar) { - if (!envVar) return {}; - return Object.fromEntries( - envVar.split(',').map(pair => { - const [claude, provider] = pair.trim().split(':'); - return [claude, provider]; - }) - ); -} - -// Map Claude model to provider model -function mapModel(claudeModel) { - const modelMap = parseModelMap(process.env.MODEL_MAP); - return modelMap[claudeModel] || claudeModel; -} - -// Transform Anthropic messages to OpenAI format -function transformMessages(request) { - const messages = []; - - // Add system message if present - if (request.system) { - messages.push({ role: 'system', content: request.system }); - } - - // Transform messages array - for (const msg of request.messages || []) { - if (typeof msg.content === 'string') { - messages.push({ role: msg.role, content: msg.content }); - } else if (Array.isArray(msg.content)) { - // Handle multi-part content - const textParts = msg.content.filter(c => c.type === 'text'); - const imageParts = msg.content.filter(c => c.type === 'image'); - - const content = []; - for (const part of textParts) { - content.push({ type: 'text', text: part.text }); - } - for (const part of imageParts) { - if (part.source?.type === 'base64') { - content.push({ - type: 'image_url', - image_url: { - url: `data:${part.source.media_type};base64,${part.source.data}` - } - }); - } else if (part.source?.type === 'url') { - content.push({ - type: 'image_url', - image_url: { url: part.source.url } - }); - } - } - - messages.push({ role: msg.role, content }); - } - } - - return messages; -} - -// Format Anthropic SSE event -function formatSSE(event, data) { - return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; -} - -export default async function handler(req, res) { - // Only allow POST - if (req.method !== 'POST') { - return res.status(405).json({ error: 'Method not allowed' }); - } - - // Validate auth token - const authHeader = req.headers.authorization || ''; - const token = authHeader.startsWith('Bearer ') - ? authHeader.slice(7) - : authHeader; - - if (token !== process.env.GATEWAY_TOKEN) { - return res.status(401).json({ error: 'Unauthorized' }); - } - - // Validate OpenAI API key - if (!process.env.OPENAI_API_KEY) { - return res.status(500).json({ error: 'OPENAI_API_KEY not configured' }); - } - - try { - const anthropicRequest = req.body; - const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY - }); - - const messages = transformMessages(anthropicRequest); - const model = mapModel(anthropicRequest.model); - const stream = anthropicRequest.stream !== false; - - if (stream) { - // Set headers for SSE - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - - const streamResponse = await openai.chat.completions.create({ - model, - messages, - stream: true, - max_tokens: anthropicRequest.max_tokens, - temperature: anthropicRequest.temperature, - top_p: anthropicRequest.top_p - }); - - let messageId = `msg_${Date.now()}`; - let inputTokens = 0; - let outputTokens = 0; - - // Send message_start event - res.write(formatSSE('message_start', { - type: 'message_start', - message: { - id: messageId, - type: 'message', - role: 'assistant', - content: [], - model: anthropicRequest.model, - stop_reason: null, - usage: { input_tokens: 0, output_tokens: 0 } - } - })); - - // Send content_block_start - res.write(formatSSE('content_block_start', { - type: 'content_block_start', - index: 0, - content_block: { type: 'text', text: '' } - })); - - let textIndex = 0; - - for await (const chunk of streamResponse) { - const delta = chunk.choices[0]?.delta; - - if (delta?.content) { - res.write(formatSSE('content_block_delta', { - type: 'content_block_delta', - index: 0, - delta: { type: 'text_delta', text: delta.content } - })); - } - - // Track usage if available - if (chunk.usage) { - inputTokens = chunk.usage.prompt_tokens || inputTokens; - outputTokens = chunk.usage.completion_tokens || outputTokens; - } - } - - // Send content_block_stop - res.write(formatSSE('content_block_stop', { - type: 'content_block_stop', - index: 0 - })); - - // Send message_delta with final usage - res.write(formatSSE('message_delta', { - type: 'message_delta', - delta: { stop_reason: 'end_turn' }, - usage: { output_tokens: outputTokens } - })); - - // Send message_stop - res.write(formatSSE('message_stop', { type: 'message_stop' })); - - res.end(); - } else { - // Non-streaming response - const response = await openai.chat.completions.create({ - model, - messages, - stream: false, - max_tokens: anthropicRequest.max_tokens, - temperature: anthropicRequest.temperature, - top_p: anthropicRequest.top_p - }); - - const content = response.choices[0]?.message?.content || ''; - - res.json({ - id: `msg_${Date.now()}`, - type: 'message', - role: 'assistant', - content: [{ type: 'text', text: content }], - model: anthropicRequest.model, - stop_reason: 'end_turn', - usage: { - input_tokens: response.usage?.prompt_tokens || 0, - output_tokens: response.usage?.completion_tokens || 0 - } - }); - } - } catch (error) { - console.error('Proxy error:', error); - - // Handle OpenAI API errors - if (error.status) { - return res.status(error.status).json({ - type: 'error', - error: { - type: 'api_error', - message: error.message - } - }); - } - - return res.status(500).json({ - type: 'error', - error: { - type: 'internal_error', - message: 'Internal server error' - } - }); - } -} diff --git a/package.json b/package.json index 24913bb..6973bc8 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,21 @@ "version": "1.0.0", "description": "A lightweight proxy for Claude Code that routes requests to third-party API providers", "private": true, + "type": "module", "scripts": { - "start": "vercel dev", - "deploy": "vercel --prod" + "dev": "hono dev", + "start": "hono start", + "deploy:vercel": "vercel --prod", + "deploy:cf": "wrangler deploy", + "dev:cf": "wrangler dev" }, "dependencies": { + "hono": "^4.6.0", "openai": "^4.85.0" }, + "devDependencies": { + "wrangler": "^3.0.0" + }, "engines": { "node": ">=18" } diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..6cdb845 --- /dev/null +++ b/src/index.js @@ -0,0 +1,27 @@ +import { Hono } from 'hono'; +import { logger } from 'hono/logger'; +import { cors } from 'hono/cors'; +import messages from './routes/messages.js'; + +const app = new Hono(); + +// Middleware +app.use('*', logger()); +app.use('*', cors()); + +// Health check +app.get('/', (c) => c.json({ status: 'ok', name: 'Claude Central Gateway' })); + +// Routes +app.route('/v1', messages); + +// 404 handler +app.notFound((c) => c.json({ error: 'Not found' }, 404)); + +// Error handler +app.onError((err, c) => { + console.error('Error:', err); + return c.json({ error: 'Internal server error' }, 500); +}); + +export default app; diff --git a/src/routes/messages.js b/src/routes/messages.js new file mode 100644 index 0000000..2f85abd --- /dev/null +++ b/src/routes/messages.js @@ -0,0 +1,223 @@ +import { Hono } from 'hono'; +import { stream } from 'hono/streaming'; +import OpenAI from 'openai'; + +const app = new Hono(); + +// Parse model mapping from env var +function parseModelMap(envVar) { + if (!envVar) return {}; + return Object.fromEntries( + envVar.split(',').map(pair => { + const [claude, provider] = pair.trim().split(':'); + return [claude, provider]; + }) + ); +} + +// Map Claude model to provider model +function mapModel(claudeModel, env) { + const modelMap = parseModelMap(env.MODEL_MAP); + return modelMap[claudeModel] || claudeModel; +} + +// Transform Anthropic messages to OpenAI format +function transformMessages(request) { + const messages = []; + + // Add system message if present + if (request.system) { + messages.push({ role: 'system', content: request.system }); + } + + // Transform messages array + for (const msg of request.messages || []) { + if (typeof msg.content === 'string') { + messages.push({ role: msg.role, content: msg.content }); + } else if (Array.isArray(msg.content)) { + // Handle multi-part content + const content = []; + + for (const part of msg.content) { + if (part.type === 'text') { + content.push({ type: 'text', text: part.text }); + } else if (part.type === 'image') { + if (part.source?.type === 'base64') { + content.push({ + type: 'image_url', + image_url: { + url: `data:${part.source.media_type};base64,${part.source.data}` + } + }); + } else if (part.source?.type === 'url') { + content.push({ + type: 'image_url', + image_url: { url: part.source.url } + }); + } + } + } + + messages.push({ role: msg.role, content }); + } + } + + return messages; +} + +// Format Anthropic SSE event +function formatSSE(event, data) { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +// Auth middleware +app.use('*', async (c, next) => { + const authHeader = c.req.header('Authorization') || ''; + const token = authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : authHeader; + + if (token !== c.env.GATEWAY_TOKEN) { + return c.json({ type: 'error', error: { type: 'authentication_error', message: 'Unauthorized' } }, 401); + } + + await next(); +}); + +// POST /v1/messages +app.post('/messages', async (c) => { + const env = c.env; + + // Validate OpenAI API key + if (!env.OPENAI_API_KEY) { + return c.json({ type: 'error', error: { type: 'api_error', message: 'OPENAI_API_KEY not configured' } }, 500); + } + + try { + const anthropicRequest = await c.req.json(); + const openai = new OpenAI({ + apiKey: env.OPENAI_API_KEY + }); + + const messages = transformMessages(anthropicRequest); + const model = mapModel(anthropicRequest.model, env); + const streamResponse = anthropicRequest.stream !== false; + + if (streamResponse) { + // Streaming response + const streamResponse = await openai.chat.completions.create({ + model, + messages, + stream: true, + max_tokens: anthropicRequest.max_tokens, + temperature: anthropicRequest.temperature, + top_p: anthropicRequest.top_p + }); + + let messageId = `msg_${Date.now()}`; + let outputTokens = 0; + + return stream(c, async (s) => { + // Send message_start event + s.write(formatSSE('message_start', { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + content: [], + model: anthropicRequest.model, + stop_reason: null, + usage: { input_tokens: 0, output_tokens: 0 } + } + })); + + // Send content_block_start + s.write(formatSSE('content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' } + })); + + for await (const chunk of streamResponse) { + const delta = chunk.choices[0]?.delta; + + if (delta?.content) { + s.write(formatSSE('content_block_delta', { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: delta.content } + })); + } + + if (chunk.usage) { + outputTokens = chunk.usage.completion_tokens || outputTokens; + } + } + + // Send content_block_stop + s.write(formatSSE('content_block_stop', { + type: 'content_block_stop', + index: 0 + })); + + // Send message_delta with final usage + s.write(formatSSE('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: outputTokens } + })); + + // Send message_stop + s.write(formatSSE('message_stop', { type: 'message_stop' })); + }); + } else { + // Non-streaming response + const response = await openai.chat.completions.create({ + model, + messages, + stream: false, + max_tokens: anthropicRequest.max_tokens, + temperature: anthropicRequest.temperature, + top_p: anthropicRequest.top_p + }); + + const content = response.choices[0]?.message?.content || ''; + + return c.json({ + id: `msg_${Date.now()}`, + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: content }], + model: anthropicRequest.model, + stop_reason: 'end_turn', + usage: { + input_tokens: response.usage?.prompt_tokens || 0, + output_tokens: response.usage?.completion_tokens || 0 + } + }); + } + } catch (error) { + console.error('Proxy error:', error); + + if (error.status) { + return c.json({ + type: 'error', + error: { + type: 'api_error', + message: error.message + } + }, error.status); + } + + return c.json({ + type: 'error', + error: { + type: 'internal_error', + message: 'Internal server error' + } + }, 500); + } +}); + +export default app; diff --git a/vercel.json b/vercel.json index 41b9b10..3b540ec 100644 --- a/vercel.json +++ b/vercel.json @@ -1,12 +1,12 @@ { + "buildCommand": "npm run build", + "devCommand": "npm run dev", + "installCommand": "npm install", + "framework": null, "rewrites": [ { - "source": "/v1/messages", - "destination": "/api/v1/messages" - }, - { - "source": "/v1/:path*", - "destination": "/api/v1/:path*" + "source": "/(.*)", + "destination": "/api/index" } ] } diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..bbf6209 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,9 @@ +name = "claude-central-gateway" +main = "src/index.js" +compatibility_date = "2024-01-01" + +[vars] +# Set these in Cloudflare dashboard or wrangler.toml +# GATEWAY_TOKEN = "your-token" +# OPENAI_API_KEY = "sk-..." +# MODEL_MAP = "claude-sonnet-4-20250514:gpt-4o"