From bfb936ae4addab923a522fd05a0405413e45e808 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 25 Mar 2026 22:47:38 +0700 Subject: [PATCH] feat: Implement OpenAI proxy with streaming support - Add main proxy endpoint (api/v1/messages.js) - Add token validation via GATEWAY_TOKEN - Add model mapping via MODEL_MAP env var - Support full SSE streaming in Anthropic format - Add package.json with openai dependency - Add vercel.json for routing config - Update README with Quick Start guide - Consolidate memory to .claude/memory/ Co-Authored-By: Claude Opus 4.6 --- .claude/memory/{memory => }/MEMORY.md | 1 + .claude/memory/implementation_plan.md | 47 ++++ .../{memory => }/memory_saving_rules.md | 0 README.md | 40 ++++ api/v1/messages.js | 226 ++++++++++++++++++ package.json | 16 ++ vercel.json | 12 + 7 files changed, 342 insertions(+) rename .claude/memory/{memory => }/MEMORY.md (50%) create mode 100644 .claude/memory/implementation_plan.md rename .claude/memory/{memory => }/memory_saving_rules.md (100%) create mode 100644 api/v1/messages.js create mode 100644 package.json create mode 100644 vercel.json diff --git a/.claude/memory/memory/MEMORY.md b/.claude/memory/MEMORY.md similarity index 50% rename from .claude/memory/memory/MEMORY.md rename to .claude/memory/MEMORY.md index 93558af..a7006b2 100644 --- a/.claude/memory/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -1,3 +1,4 @@ # Project Memory Index - [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 diff --git a/.claude/memory/implementation_plan.md b/.claude/memory/implementation_plan.md new file mode 100644 index 0000000..ab4af31 --- /dev/null +++ b/.claude/memory/implementation_plan.md @@ -0,0 +1,47 @@ +--- +name: Implementation Plan +description: Architecture and design decisions for Claude Central Gateway +type: project +--- + +## Claude Central Gateway - Implementation + +**Why:** A lightweight proxy for Claude Code that routes requests to third-party API providers, deployable to Vercel without needing a VPS. + +### Architecture + +``` +Claude Code → Gateway (Vercel) → OpenAI API + ↓ + Validate token + Transform request + Stream response +``` + +### Key Decisions + +- **Language**: Node.js with JavaScript (no TypeScript) +- **Deployment**: Vercel serverless functions +- **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`) +- **Streaming**: Full streaming support +- **Model mapping**: Via `MODEL_MAP` env var (format: `claude:openai,claude2:openai2`) + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `GATEWAY_TOKEN` | Shared token for authentication | +| `OPENAI_API_KEY` | OpenAI API key | +| `MODEL_MAP` | Model name mapping (optional) | + +### File Structure + +``` +api/v1/messages.js - Main proxy handler +package.json - Dependencies (openai SDK) +vercel.json - Routing 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/.claude/memory/memory/memory_saving_rules.md b/.claude/memory/memory_saving_rules.md similarity index 100% rename from .claude/memory/memory/memory_saving_rules.md rename to .claude/memory/memory_saving_rules.md diff --git a/README.md b/README.md index 8d6770d..caaba6f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,46 @@ Check out [this repo](https://github.com/tiennm99/penny-pincher-provider) for a Minimal, simple, deploy anywhere. +## Quick Start + +### 1. Deploy to Vercel + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/tiennm99/claude-central-gateway) + +Or manually: + +```bash +git clone https://github.com/tiennm99/claude-central-gateway +cd claude-central-gateway +vercel +``` + +### 2. Set Environment Variables + +In Vercel dashboard, set these environment variables: + +| Variable | Description | Example | +|----------|-------------|---------| +| `GATEWAY_TOKEN` | Shared token for authentication | `my-secret-token` | +| `OPENAI_API_KEY` | Your OpenAI API key | `sk-...` | +| `MODEL_MAP` | Model name mapping | `claude-sonnet-4-20250514:gpt-4o` | + +### 3. Configure Claude Code + +```bash +export ANTHROPIC_BASE_URL=https://your-gateway.vercel.app +export ANTHROPIC_AUTH_TOKEN=my-secret-token +claude +``` + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `GATEWAY_TOKEN` | Yes | Token users must provide in `ANTHROPIC_AUTH_TOKEN` | +| `OPENAI_API_KEY` | Yes | OpenAI API key | +| `MODEL_MAP` | No | Comma-separated model mappings (format: `claude:openai`) | + ## Why This Project? ### Why not use a local proxy, like [Claude Code Router](https://github.com/musistudio/claude-code-router)? diff --git a/api/v1/messages.js b/api/v1/messages.js new file mode 100644 index 0000000..b081fbb --- /dev/null +++ b/api/v1/messages.js @@ -0,0 +1,226 @@ +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 new file mode 100644 index 0000000..24913bb --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "claude-central-gateway", + "version": "1.0.0", + "description": "A lightweight proxy for Claude Code that routes requests to third-party API providers", + "private": true, + "scripts": { + "start": "vercel dev", + "deploy": "vercel --prod" + }, + "dependencies": { + "openai": "^4.85.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..41b9b10 --- /dev/null +++ b/vercel.json @@ -0,0 +1,12 @@ +{ + "rewrites": [ + { + "source": "/v1/messages", + "destination": "/api/v1/messages" + }, + { + "source": "/v1/:path*", + "destination": "/api/v1/:path*" + } + ] +}