diff --git a/.gitignore b/.gitignore index 4bba6f20..c433aefe 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ agent/test/UPGRADE_PLAN.md introduction/* !sandbox/docker/build.sh sandbox/docker/yao-bridge-* +sandbox/docker/claude-proxy-* diff --git a/agent/sandbox/claude/command.go b/agent/sandbox/claude/command.go index 5409807f..d5507a2a 100644 --- a/agent/sandbox/claude/command.go +++ b/agent/sandbox/claude/command.go @@ -13,34 +13,33 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map // Build system prompt from conversation history systemPrompt, userPrompt := buildPrompts(messages) - // Build the ccr code command with all arguments - // We use bash -c to ensure CCR is started first, then run ccr code with proper argument handling - var ccrArgs []string + // Build Claude CLI arguments + var claudeArgs []string // Add permission mode (required for MCP tools to work) - permMode := "acceptEdits" // default + permMode := "bypassPermissions" // default for sandbox if opts != nil && opts.Arguments != nil { if mode, ok := opts.Arguments["permission_mode"].(string); ok && mode != "" { permMode = mode } } - ccrArgs = append(ccrArgs, "--permission-mode", permMode) + claudeArgs = append(claudeArgs, "--dangerously-skip-permissions") + claudeArgs = append(claudeArgs, "--permission-mode", permMode) // Add MCP config if available if opts != nil && len(opts.MCPConfig) > 0 { - ccrArgs = append(ccrArgs, "--mcp-config", "/workspace/.mcp.json") + claudeArgs = append(claudeArgs, "--mcp-config", "/workspace/.mcp.json") // Allow all tools from the "yao" MCP server - ccrArgs = append(ccrArgs, "--allowedTools", "mcp__yao__*") + claudeArgs = append(claudeArgs, "--allowedTools", "mcp__yao__*") } // Build the full bash command - // Start CCR daemon, wait, then run ccr code with arguments - bashCmd := "nohup ccr start >/dev/null 2>&1 & sleep 2; ccr code" - for _, arg := range ccrArgs { + // claude-proxy is already started by prepareEnvironment + bashCmd := "claude -p" + for _, arg := range claudeArgs { // Quote arguments that might contain special characters bashCmd += fmt.Sprintf(" %q", arg) } - bashCmd += " -p" if userPrompt != "" { bashCmd += fmt.Sprintf(" %q", userPrompt) } @@ -123,20 +122,9 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string { return env } - // CCR configuration via environment - // CCR (Claude Code Router) transforms OpenAI-compatible API to Anthropic API format - if opts.ConnectorHost != "" { - // CCR expects ANTHROPIC_BASE_URL but will proxy through its own router - env["CCR_API_BASE"] = opts.ConnectorHost - } - - if opts.ConnectorKey != "" { - env["CCR_API_KEY"] = opts.ConnectorKey - } - - if opts.Model != "" { - env["CCR_MODEL"] = opts.Model - } + // claude-proxy runs on localhost:3456, Claude CLI connects to it + env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456" + env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this // Set system prompt via environment (Claude CLI supports this) if systemPrompt != "" { @@ -150,11 +138,6 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string { env["CLAUDE_MAX_TURNS"] = fmt.Sprintf("%v", maxTurns) } - // permission_mode - if permMode, ok := opts.Arguments["permission_mode"].(string); ok { - env["CLAUDE_PERMISSION_MODE"] = permMode - } - // output_format (default to stream-json for streaming) if outputFormat, ok := opts.Arguments["output_format"].(string); ok { env["CLAUDE_OUTPUT_FORMAT"] = outputFormat @@ -168,73 +151,30 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string { return env } -// BuildCCRConfig builds the CCR (Claude Code Router) configuration JSON -// CCR requires a specific format with Providers array and Router configuration -func BuildCCRConfig(opts *Options) ([]byte, error) { +// BuildProxyConfig builds the claude-proxy configuration JSON +// This config file is read by start-claude-proxy script in the container +func BuildProxyConfig(opts *Options) ([]byte, error) { if opts == nil { return nil, fmt.Errorf("options is required") } - // Determine provider name based on host - providerName := "custom" - apiBaseURL := opts.ConnectorHost - needsTransformer := false - - if strings.Contains(opts.ConnectorHost, "volces.com") || strings.Contains(opts.ConnectorHost, "volcengine") { - providerName = "volcengine" - needsTransformer = true - // Ensure URL ends with chat/completions - if !strings.HasSuffix(apiBaseURL, "/chat/completions") { - apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/chat/completions" - } - } else if strings.Contains(opts.ConnectorHost, "deepseek") { - providerName = "deepseek" - needsTransformer = true - if !strings.HasSuffix(apiBaseURL, "/chat/completions") { - apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/chat/completions" - } - } else if strings.Contains(opts.ConnectorHost, "openai.com") { - providerName = "openai" - if !strings.HasSuffix(apiBaseURL, "/chat/completions") { - apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/v1/chat/completions" - } - } else if strings.Contains(opts.ConnectorHost, "anthropic.com") { - providerName = "claude" + // Build backend URL - ensure it ends with /chat/completions + backendURL := opts.ConnectorHost + if !strings.HasSuffix(backendURL, "/chat/completions") { + backendURL = strings.TrimSuffix(backendURL, "/") + "/chat/completions" } - // Build provider configuration - provider := map[string]interface{}{ - "name": providerName, - "api_base_url": apiBaseURL, - "api_key": opts.ConnectorKey, - "models": []string{opts.Model}, - } - - // Add transformer for providers that need it (DeepSeek, Volcengine) - if needsTransformer { - provider["transformer"] = map[string]interface{}{ - "use": []interface{}{ - []interface{}{"maxtoken", map[string]interface{}{"max_tokens": 16384}}, - }, - } - } - - // Build router configuration - routerKey := fmt.Sprintf("%s,%s", providerName, opts.Model) - router := map[string]interface{}{ - "default": routerKey, - "background": routerKey, - "think": routerKey, - } - - // Build full config config := map[string]interface{}{ - "LOG": true, - "API_TIMEOUT_MS": 600000, - "NON_INTERACTIVE_MODE": true, - "Providers": []interface{}{provider}, - "Router": router, + "backend": backendURL, + "api_key": opts.ConnectorKey, + "model": opts.Model, } return json.MarshalIndent(config, "", " ") } + +// BuildCCRConfig is deprecated, kept for backward compatibility +// Use BuildProxyConfig instead +func BuildCCRConfig(opts *Options) ([]byte, error) { + return BuildProxyConfig(opts) +} diff --git a/agent/sandbox/claude/executor.go b/agent/sandbox/claude/executor.go index eeeb6615..94f6821c 100644 --- a/agent/sandbox/claude/executor.go +++ b/agent/sandbox/claude/executor.go @@ -139,11 +139,11 @@ func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Mes } // prepareEnvironment prepares the container environment before execution -// This includes: CCR config, MCP config, and Skills directory +// This includes: claude-proxy config, MCP config, and Skills directory func (e *Executor) prepareEnvironment(ctx context.Context) error { - // 1. Write CCR config (Claude Code Router configuration) - if err := e.writeCCRConfig(ctx); err != nil { - return fmt.Errorf("failed to write CCR config: %w", err) + // 1. Write claude-proxy config and start the proxy + if err := e.startClaudeProxy(ctx); err != nil { + return fmt.Errorf("failed to start claude-proxy: %w", err) } // 2. Write MCP config if provided @@ -165,20 +165,34 @@ func (e *Executor) prepareEnvironment(ctx context.Context) error { return nil } -// writeCCRConfig writes the CCR configuration file to the container -func (e *Executor) writeCCRConfig(ctx context.Context) error { - // Build CCR config - configJSON, err := BuildCCRConfig(e.opts) +// startClaudeProxy writes proxy config and starts claude-proxy +func (e *Executor) startClaudeProxy(ctx context.Context) error { + // Build proxy config + configJSON, err := BuildProxyConfig(e.opts) if err != nil { - return fmt.Errorf("failed to build CCR config: %w", err) + return fmt.Errorf("failed to build proxy config: %w", err) } - // Write config to container's CCR directory - configPath := "/home/sandbox/.claude-code-router/config.json" + // Write config to workspace + configPath := e.workDir + "/.claude-proxy.json" if err := e.manager.WriteFile(ctx, e.containerName, configPath, configJSON); err != nil { return fmt.Errorf("failed to write config to %s: %w", configPath, err) } + // Start the proxy + result, err := e.manager.Exec(ctx, e.containerName, []string{"start-claude-proxy"}, &infraSandbox.ExecOptions{ + WorkDir: e.workDir, + Env: map[string]string{ + "WORKSPACE": e.workDir, + }, + }) + if err != nil { + return fmt.Errorf("failed to start claude-proxy: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("claude-proxy failed to start: %s", result.Stderr) + } + return nil } diff --git a/sandbox/docker/build.sh b/sandbox/docker/build.sh index 891d6e3a..af99e3bd 100755 --- a/sandbox/docker/build.sh +++ b/sandbox/docker/build.sh @@ -30,6 +30,21 @@ echo "Built: yao-bridge-amd64, yao-bridge-arm64" cd "$SCRIPT_DIR" +# Build claude-proxy for both architectures +echo "" +echo "=== Building claude-proxy (multi-arch) ===" +cd "$SCRIPT_DIR/../proxy/cmd/claude-proxy" + +echo "Building for linux/amd64..." +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/claude-proxy-amd64" . + +echo "Building for linux/arm64..." +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/claude-proxy-arm64" . + +echo "Built: claude-proxy-amd64, claude-proxy-arm64" + +cd "$SCRIPT_DIR" + # Check if buildx is available and set up setup_buildx() { echo "" @@ -141,4 +156,5 @@ docker images | grep -E "(sandbox-base|sandbox-claude|sandbox-cursor)" | head -1 echo "" echo "=== Cleanup ===" rm -f "$SCRIPT_DIR/yao-bridge-amd64" "$SCRIPT_DIR/yao-bridge-arm64" +rm -f "$SCRIPT_DIR/claude-proxy-amd64" "$SCRIPT_DIR/claude-proxy-arm64" echo "Removed temporary binary files" diff --git a/sandbox/docker/claude/Dockerfile b/sandbox/docker/claude/Dockerfile index 6ecb4e90..62a1ae4d 100644 --- a/sandbox/docker/claude/Dockerfile +++ b/sandbox/docker/claude/Dockerfile @@ -1,4 +1,4 @@ -# Claude sandbox image: Claude CLI + Node.js + Python + CCR +# Claude sandbox image: Claude CLI + Node.js + Python + claude-proxy # Supports both amd64 and arm64 architectures # Base: Ubuntu 24.04 LTS ARG REGISTRY=yaoapp @@ -39,54 +39,223 @@ ENV PATH="/home/sandbox/.npm-global/bin:${PATH}" RUN npm install -g @anthropic-ai/claude-code || \ echo "Claude CLI installation skipped (may not be available yet)" -# Install Claude Code Router (CCR) for third-party LLM support -# Supports: DeepSeek, GLM, Volcengine, OpenRouter, etc. -RUN npm install -g @musistudio/claude-code-router - -# Create CCR config directory -RUN mkdir -p /home/sandbox/.claude-code-router - -# Create entrypoint script for CCR daemon mode USER root -RUN cat > /usr/local/bin/ccr-run << 'SCRIPT' + +# Install claude-proxy (architecture-specific binary) +ARG TARGETARCH +COPY claude-proxy-${TARGETARCH} /usr/local/bin/claude-proxy +RUN chmod +x /usr/local/bin/claude-proxy + +# Create claude-proxy startup script +RUN cat > /usr/local/bin/start-proxy << 'SCRIPT' #!/bin/bash -# CCR wrapper: starts CCR daemon and runs ccr code -# Usage: ccr-run "prompt" or ccr-run -c /path/to/config.json "prompt" +# Claude Proxy startup script +# Usage: start-proxy [options] +# Options are passed directly to claude-proxy -CONFIG="" -while [[ $# -gt 0 ]]; do - case $1 in - -c|--config) - CONFIG="$2" - shift 2 - ;; - *) - break - ;; - esac -done +LOG_DIR="${WORKSPACE:-/workspace}" +LOG_FILE="${LOG_DIR}/proxy.log" -# Apply config if provided -if [ -n "$CONFIG" ] && [ -f "$CONFIG" ]; then - cp "$CONFIG" ~/.claude-code-router/config.json +# Ensure log directory exists +mkdir -p "$LOG_DIR" 2>/dev/null || true + +# Default environment variables (can be overridden) +export CLAUDE_PROXY_PORT="${CLAUDE_PROXY_PORT:-3456}" + +# Start proxy with logging +exec /usr/local/bin/claude-proxy -v -l "$LOG_FILE" "$@" +SCRIPT +RUN chmod +x /usr/local/bin/start-proxy + +# Create claude-run wrapper for easy usage (manual mode) +RUN cat > /usr/local/bin/claude-run << 'SCRIPT' +#!/bin/bash +# Claude CLI wrapper with proxy auto-start +# Usage: claude-run [claude options] "prompt" +# +# Environment variables: +# CLAUDE_PROXY_BACKEND - Backend API URL (required) +# CLAUDE_PROXY_API_KEY - Backend API Key (required) +# CLAUDE_PROXY_MODEL - Backend model name (required) +# CLAUDE_PROXY_PORT - Proxy port (default: 3456) +# WORKSPACE - Working directory (default: /workspace) + +set -e + +# Check required environment variables +if [ -z "$CLAUDE_PROXY_BACKEND" ]; then + echo "Error: CLAUDE_PROXY_BACKEND is not set" + echo "Example: export CLAUDE_PROXY_BACKEND=https://ark.cn-beijing.volces.com/api/v3/chat/completions" + exit 1 fi -# Start CCR daemon (nohup because ccr start -d doesn't work in containers) -nohup ccr start >/dev/null 2>&1 & -sleep 2 +if [ -z "$CLAUDE_PROXY_API_KEY" ]; then + echo "Error: CLAUDE_PROXY_API_KEY is not set" + exit 1 +fi -# Run ccr code -exec ccr code -p "$*" +if [ -z "$CLAUDE_PROXY_MODEL" ]; then + echo "Error: CLAUDE_PROXY_MODEL is not set" + echo "Example: export CLAUDE_PROXY_MODEL=glm-4-7-251222" + exit 1 +fi + +PORT="${CLAUDE_PROXY_PORT:-3456}" +WORKSPACE="${WORKSPACE:-/workspace}" +LOG_FILE="${WORKSPACE}/proxy.log" + +# Check if proxy is already running +if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then + echo "Proxy already running on port $PORT" +else + echo "Starting claude-proxy..." + mkdir -p "$WORKSPACE" 2>/dev/null || true + nohup /usr/local/bin/claude-proxy -v -l "$LOG_FILE" > /dev/null 2>&1 & + + # Wait for proxy to start + for i in {1..10}; do + if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then + echo "Proxy started successfully" + break + fi + sleep 0.5 + done + + if ! curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then + echo "Error: Failed to start proxy" + exit 1 + fi +fi + +# Set Claude CLI environment +export ANTHROPIC_BASE_URL="http://127.0.0.1:${PORT}" +export ANTHROPIC_API_KEY="dummy" + +# Change to workspace directory +cd "$WORKSPACE" + +# Run Claude CLI with all arguments +exec claude "$@" SCRIPT -RUN chmod +x /usr/local/bin/ccr-run +RUN chmod +x /usr/local/bin/claude-run + +# Create start-claude-proxy script for programmatic use (called by Yao) +# This reads proxy config from /workspace/.claude-proxy.json if exists +RUN cat > /usr/local/bin/start-claude-proxy << 'SCRIPT' +#!/bin/bash +# Start claude-proxy from config file or environment variables +# Config file: /workspace/.claude-proxy.json +# Format: {"backend": "...", "api_key": "...", "model": "..."} + +CONFIG_FILE="${WORKSPACE:-/workspace}/.claude-proxy.json" +LOG_FILE="${WORKSPACE:-/workspace}/proxy.log" +PORT="${CLAUDE_PROXY_PORT:-3456}" + +# Try to read from config file first +if [ -f "$CONFIG_FILE" ]; then + BACKEND=$(jq -r '.backend // empty' "$CONFIG_FILE" 2>/dev/null) + API_KEY=$(jq -r '.api_key // empty' "$CONFIG_FILE" 2>/dev/null) + MODEL=$(jq -r '.model // empty' "$CONFIG_FILE" 2>/dev/null) + + if [ -n "$BACKEND" ]; then + export CLAUDE_PROXY_BACKEND="$BACKEND" + fi + if [ -n "$API_KEY" ]; then + export CLAUDE_PROXY_API_KEY="$API_KEY" + fi + if [ -n "$MODEL" ]; then + export CLAUDE_PROXY_MODEL="$MODEL" + fi +fi + +# Check if we have the required config +if [ -z "$CLAUDE_PROXY_BACKEND" ] || [ -z "$CLAUDE_PROXY_API_KEY" ] || [ -z "$CLAUDE_PROXY_MODEL" ]; then + echo "Error: Missing proxy configuration" + echo "Either set environment variables or create $CONFIG_FILE" + exit 1 +fi + +# Check if already running +if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then + echo "claude-proxy already running" + exit 0 +fi + +# Start proxy +mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true +nohup /usr/local/bin/claude-proxy -v -l "$LOG_FILE" > /dev/null 2>&1 & + +# Wait for startup +for i in {1..20}; do + if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then + echo "claude-proxy started on port $PORT" + exit 0 + fi + sleep 0.5 +done + +echo "Error: claude-proxy failed to start" +exit 1 +SCRIPT +RUN chmod +x /usr/local/bin/start-claude-proxy + +# Create entrypoint script +# Note: claude-proxy is started on-demand by Yao (via start-claude-proxy) +# or manually by user (via claude-run) +RUN cat > /usr/local/bin/entrypoint.sh << 'SCRIPT' +#!/bin/bash +# Container entrypoint +# claude-proxy is NOT auto-started here - it's started by: +# 1. Yao's sandbox executor (writes config to .claude-proxy.json, calls start-claude-proxy) +# 2. Manual usage via claude-run command +# 3. Direct invocation of start-claude-proxy + +WORKSPACE="${WORKSPACE:-/workspace}" +PORT="${CLAUDE_PROXY_PORT:-3456}" +ENV_FILE="/tmp/claude-proxy-env" + +# If proxy env vars are set AND proxy is not running, start it +# This supports docker run -e CLAUDE_PROXY_BACKEND=... usage +if [ -n "$CLAUDE_PROXY_BACKEND" ] && [ -n "$CLAUDE_PROXY_API_KEY" ] && [ -n "$CLAUDE_PROXY_MODEL" ]; then + if ! curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then + /usr/local/bin/start-claude-proxy + fi + + # Write env vars to a file that can be sourced + if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then + echo "export ANTHROPIC_BASE_URL=http://127.0.0.1:${PORT}" > "$ENV_FILE" + echo "export ANTHROPIC_API_KEY=dummy" >> "$ENV_FILE" + chmod 644 "$ENV_FILE" + fi +fi + +# Execute the command passed to docker run +exec "$@" +SCRIPT +RUN chmod +x /usr/local/bin/entrypoint.sh + +# Create a wrapper that sources the env file +RUN cat > /usr/local/bin/claude-env << 'SCRIPT' +#!/bin/bash +# Source claude-proxy environment if available +if [ -f /tmp/claude-proxy-env ]; then + source /tmp/claude-proxy-env +fi +exec "$@" +SCRIPT +RUN chmod +x /usr/local/bin/claude-env + +# Add sourcing to global bashrc so docker exec gets the vars +RUN echo '[ -f /tmp/claude-proxy-env ] && source /tmp/claude-proxy-env' >> /etc/bash.bashrc USER sandbox # Verify installations RUN node --version && npm --version && python3 --version && \ claude --version || true && \ - ccr --version || true + claude-proxy --help 2>&1 | head -1 || true WORKDIR /workspace +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["sleep", "infinity"] diff --git a/sandbox/proxy/README.md b/sandbox/proxy/README.md new file mode 100644 index 00000000..fa2c6420 --- /dev/null +++ b/sandbox/proxy/README.md @@ -0,0 +1,214 @@ +# Claude API Proxy + +A lightweight API proxy that allows Claude CLI to use any OpenAI-compatible backend API (Volcengine, DeepSeek, GLM, etc.). + +## Features + +- **Zero dependencies**: Uses only Go standard library +- **Lightweight**: Single executable binary +- **True streaming**: Direct SSE forwarding, no buffering +- **Full tool calling support**: Both streaming and non-streaming +- **Image content support**: Base64 and URL formats +- **Multi-architecture**: Supports amd64 and arm64 + +## Architecture + +``` +Claude CLI (Anthropic Messages API) + │ + ▼ +claude-proxy (localhost:3456) + │ + │ Convert: Anthropic → OpenAI + ▼ +OpenAI-compatible Backend (Volcengine/DeepSeek/GLM...) + │ + │ Convert: OpenAI → Anthropic + ▼ +Claude CLI (Real-time streaming output) +``` + +## Command Line Options + +```bash +claude-proxy [options] + +Options: + -p, --port Listen port (default: 3456) + -b, --backend Backend API URL (required) + -m, --model Backend model name (required) + -k, --api-key Backend API key (required) + -l, --log Log file path + -t, --timeout Request timeout (default: 300) + -v, --verbose Verbose logging + -h, --help Show help + +Environment Variables: + CLAUDE_PROXY_PORT Listen port + CLAUDE_PROXY_BACKEND Backend API URL + CLAUDE_PROXY_MODEL Backend model name + CLAUDE_PROXY_API_KEY Backend API key + CLAUDE_PROXY_TIMEOUT Timeout in seconds +``` + +## Usage + +### Method 1: Command Line Arguments + +```bash +# Using Volcengine GLM-4 +claude-proxy -b https://ark.cn-beijing.volces.com/api/v3/chat/completions \ + -m glm-4-7-251222 \ + -k your-api-key \ + -v + +# Using DeepSeek +claude-proxy -b https://api.deepseek.com/chat/completions \ + -m deepseek-chat \ + -k your-api-key +``` + +### Method 2: Environment Variables + +```bash +export CLAUDE_PROXY_BACKEND="https://ark.cn-beijing.volces.com/api/v3/chat/completions" +export CLAUDE_PROXY_API_KEY="your-api-key" +export CLAUDE_PROXY_MODEL="glm-4-7-251222" +claude-proxy -v +``` + +### Method 3: Config File (Inside Container) + +Create config file at `/workspace/.claude-proxy.json`: + +```json +{ + "backend": "https://ark.cn-beijing.volces.com/api/v3/chat/completions", + "api_key": "your-api-key", + "model": "glm-4-7-251222" +} +``` + +Then run: + +```bash +start-claude-proxy +``` + +## Container Usage + +### Docker Run (via Environment Variables) + +```bash +docker run -d \ + -e CLAUDE_PROXY_BACKEND="https://ark.cn-beijing.volces.com/api/v3/chat/completions" \ + -e CLAUDE_PROXY_API_KEY="your-api-key" \ + -e CLAUDE_PROXY_MODEL="your-model-name" \ + yaoapp/sandbox-claude:latest +``` + +The container will automatically start claude-proxy on startup. + +### Using claude-run Wrapper + +```bash +# Enter the container +docker exec -it bash + +# Set environment variables +export CLAUDE_PROXY_BACKEND="https://ark.cn-beijing.volces.com/api/v3/chat/completions" +export CLAUDE_PROXY_API_KEY="your-api-key" +export CLAUDE_PROXY_MODEL="your-model-name" + +# Use claude-run wrapper (auto-starts proxy) +claude-run --dangerously-skip-permissions "Build me a website" +``` + +### Direct Claude CLI Usage + +```bash +# Ensure proxy is running +curl http://127.0.0.1:3456/health + +# Set Claude CLI environment +export ANTHROPIC_BASE_URL=http://127.0.0.1:3456 +export ANTHROPIC_API_KEY=dummy + +# Use Claude CLI +claude -p --dangerously-skip-permissions --permission-mode bypassPermissions "Build me a website" +``` + +## Claude CLI Common Options + +```bash +# Basic usage (max permissions, no questions) +claude -p --dangerously-skip-permissions --permission-mode bypassPermissions "your task" + +# Streaming JSON output +claude -p --dangerously-skip-permissions --output-format stream-json --verbose "your task" + +# Interactive mode (real-time streaming output) +claude --dangerously-skip-permissions "your task" +``` + +### Option Reference + +| Option | Description | +|--------|-------------| +| `-p, --print` | Print mode, exit after output | +| `--dangerously-skip-permissions` | Skip all permission checks | +| `--permission-mode bypassPermissions` | Bypass permission mode | +| `--output-format stream-json` | Output JSON stream | +| `--verbose` | Verbose output (required for stream-json) | + +## Viewing Logs + +```bash +# View proxy logs inside container +tail -f /workspace/proxy.log + +# Check health status +curl http://127.0.0.1:3456/health +``` + +## Supported Backends + +| Backend | API URL | +|---------|---------| +| Volcengine GLM | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` | +| Volcengine DeepSeek | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` | +| DeepSeek Official | `https://api.deepseek.com/chat/completions` | +| OpenAI | `https://api.openai.com/v1/chat/completions` | +| Other OpenAI-compatible APIs | Custom URL | + +## API Endpoints + +### POST /v1/messages + +Main endpoint, accepts Anthropic Messages API format requests. + +### GET /health + +Health check endpoint, returns `{"status": "ok"}`. + +## Building + +```bash +# Local build +go build -o claude-proxy ./cmd/claude-proxy/ + +# Cross-compile +GOOS=linux GOARCH=amd64 go build -o claude-proxy-amd64 ./cmd/claude-proxy/ +GOOS=linux GOARCH=arm64 go build -o claude-proxy-arm64 ./cmd/claude-proxy/ +``` + +## Yao Integration + +Yao's sandbox executor automatically: + +1. Writes connector config to `/workspace/.claude-proxy.json` when creating container +2. Calls `start-claude-proxy` to start the proxy +3. Sets `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` environment variables +4. Executes Claude CLI commands + +No manual configuration required. diff --git a/sandbox/proxy/cmd/claude-proxy/main.go b/sandbox/proxy/cmd/claude-proxy/main.go new file mode 100644 index 00000000..4c9aa32a --- /dev/null +++ b/sandbox/proxy/cmd/claude-proxy/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/yaoapp/yao/sandbox/proxy" + +func main() { + proxy.Main() +} diff --git a/sandbox/proxy/convert.go b/sandbox/proxy/convert.go new file mode 100644 index 00000000..7a5bd58f --- /dev/null +++ b/sandbox/proxy/convert.go @@ -0,0 +1,423 @@ +package proxy + +import ( + "encoding/json" + "fmt" +) + +// convertRequest converts an Anthropic request to OpenAI format +func (s *Server) convertRequest(req *AnthropicRequest) *OpenAIRequest { + // Limit max_tokens to backend's maximum (most models support 16384) + maxTokens := req.MaxTokens + if maxTokens > 16384 { + maxTokens = 16384 + } + + openaiReq := &OpenAIRequest{ + Model: s.config.Model, + MaxTokens: maxTokens, + Stream: req.Stream, + Temperature: req.Temperature, + TopP: req.TopP, + Stop: req.StopSequences, + } + + // Convert messages + openaiReq.Messages = s.convertMessages(req.Messages, req.System) + + // Convert tools + if len(req.Tools) > 0 { + openaiReq.Tools = s.convertTools(req.Tools) + } + + // Convert tool choice + if req.ToolChoice != nil { + openaiReq.ToolChoice = s.convertToolChoice(req.ToolChoice) + } + + return openaiReq +} + +// convertMessages converts Anthropic messages to OpenAI format +func (s *Server) convertMessages(msgs []AnthropicMsg, system interface{}) []OpenAIMsg { + var result []OpenAIMsg + + // Handle system message + if system != nil { + systemText := extractSystemText(system) + if systemText != "" { + result = append(result, OpenAIMsg{ + Role: "system", + Content: systemText, + }) + } + } + + // Convert each message + for _, msg := range msgs { + converted := s.convertMessage(msg) + result = append(result, converted...) + } + + return result +} + +// convertMessage converts a single Anthropic message to OpenAI format +func (s *Server) convertMessage(msg AnthropicMsg) []OpenAIMsg { + var result []OpenAIMsg + + // Handle content + switch content := msg.Content.(type) { + case string: + result = append(result, OpenAIMsg{ + Role: mapRole(msg.Role), + Content: content, + }) + + case []interface{}: + // Check if this contains tool results + var toolResults []ContentBlock + var otherContent []interface{} + + for _, item := range content { + block := parseContentBlock(item) + if block.Type == "tool_result" { + toolResults = append(toolResults, block) + } else { + otherContent = append(otherContent, item) + } + } + + // Convert tool results to separate tool messages + for _, tr := range toolResults { + toolMsg := OpenAIMsg{ + Role: "tool", + ToolCallID: tr.ToolUseID, + Content: extractToolResultContent(tr.Content), + } + result = append(result, toolMsg) + } + + // Convert other content + if len(otherContent) > 0 { + openaiContent := s.convertContentBlocks(otherContent) + if len(openaiContent) == 1 && openaiContent[0].Type == "text" { + result = append(result, OpenAIMsg{ + Role: mapRole(msg.Role), + Content: openaiContent[0].Text, + }) + } else if len(openaiContent) > 0 { + result = append(result, OpenAIMsg{ + Role: mapRole(msg.Role), + Content: openaiContent, + }) + } + } + + // Handle assistant message with tool_use + if msg.Role == "assistant" { + toolCalls := extractToolUseBlocks(content) + if len(toolCalls) > 0 { + // Find or create assistant message + found := false + for i := range result { + if result[i].Role == "assistant" { + result[i].ToolCalls = toolCalls + found = true + break + } + } + if !found { + result = append(result, OpenAIMsg{ + Role: "assistant", + Content: "", + ToolCalls: toolCalls, + }) + } + } + } + } + + return result +} + +// convertContentBlocks converts Anthropic content blocks to OpenAI format +func (s *Server) convertContentBlocks(blocks []interface{}) []OpenAIContent { + var result []OpenAIContent + + for _, item := range blocks { + block := parseContentBlock(item) + + switch block.Type { + case "text": + result = append(result, OpenAIContent{ + Type: "text", + Text: block.Text, + }) + + case "image": + if block.Source != nil { + imageURL := convertImageSource(block.Source) + result = append(result, OpenAIContent{ + Type: "image_url", + ImageURL: imageURL, + }) + } + + case "tool_use", "tool_result": + // Handled separately + continue + } + } + + return result +} + +// convertImageSource converts Anthropic image source to OpenAI image URL +func convertImageSource(source *ImageSource) *OpenAIImageURL { + if source == nil { + return nil + } + + switch source.Type { + case "base64": + // Convert to data URI + mediaType := source.MediaType + if mediaType == "" { + mediaType = "image/jpeg" + } + return &OpenAIImageURL{ + URL: fmt.Sprintf("data:%s;base64,%s", mediaType, source.Data), + } + case "url": + return &OpenAIImageURL{ + URL: source.URL, + } + } + + return nil +} + +// convertTools converts Anthropic tools to OpenAI format +func (s *Server) convertTools(tools []AnthropicTool) []OpenAITool { + var result []OpenAITool + + for _, tool := range tools { + result = append(result, OpenAITool{ + Type: "function", + Function: OpenAIFunction{ + Name: tool.Name, + Description: tool.Description, + Parameters: tool.InputSchema, + }, + }) + } + + return result +} + +// convertToolChoice converts Anthropic tool choice to OpenAI format +func (s *Server) convertToolChoice(choice *AnthropicToolChoice) interface{} { + if choice == nil { + return nil + } + + switch choice.Type { + case "auto": + return "auto" + case "any": + return "required" + case "tool": + return map[string]interface{}{ + "type": "function", + "function": map[string]string{ + "name": choice.Name, + }, + } + case "none": + return "none" + } + + return "auto" +} + +// convertResponse converts an OpenAI response to Anthropic format +func (s *Server) convertResponse(resp *OpenAIResponse) *AnthropicResponse { + result := &AnthropicResponse{ + ID: generateID("msg_"), + Type: "message", + Role: "assistant", + Content: []ContentBlock{}, + Model: s.config.Model, + } + + if len(resp.Choices) > 0 { + choice := resp.Choices[0] + + // Convert content + if content, ok := choice.Message.Content.(string); ok && content != "" { + result.Content = append(result.Content, ContentBlock{ + Type: "text", + Text: content, + }) + } + + // Convert tool calls + for _, tc := range choice.Message.ToolCalls { + var input interface{} + json.Unmarshal([]byte(tc.Function.Arguments), &input) + + result.Content = append(result.Content, ContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: input, + }) + } + + // Convert stop reason + stopReason := mapFinishReason(choice.FinishReason) + result.StopReason = &stopReason + } + + // Convert usage + if resp.Usage != nil { + result.Usage = &Usage{ + InputTokens: resp.Usage.PromptTokens, + OutputTokens: resp.Usage.CompletionTokens, + } + } + + return result +} + +// Helper functions + +func extractSystemText(system interface{}) string { + switch s := system.(type) { + case string: + return s + case []interface{}: + var texts []string + for _, item := range s { + if block, ok := item.(map[string]interface{}); ok { + if text, ok := block["text"].(string); ok { + texts = append(texts, text) + } + } + } + if len(texts) > 0 { + return texts[0] // Return first system text + } + } + return "" +} + +func parseContentBlock(item interface{}) ContentBlock { + var block ContentBlock + + switch v := item.(type) { + case map[string]interface{}: + if t, ok := v["type"].(string); ok { + block.Type = t + } + if text, ok := v["text"].(string); ok { + block.Text = text + } + if id, ok := v["id"].(string); ok { + block.ID = id + } + if name, ok := v["name"].(string); ok { + block.Name = name + } + if input, ok := v["input"]; ok { + block.Input = input + } + if toolUseID, ok := v["tool_use_id"].(string); ok { + block.ToolUseID = toolUseID + } + if content, ok := v["content"]; ok { + block.Content = content + } + if isError, ok := v["is_error"].(bool); ok { + block.IsError = isError + } + if source, ok := v["source"].(map[string]interface{}); ok { + block.Source = parseImageSource(source) + } + } + + return block +} + +func parseImageSource(source map[string]interface{}) *ImageSource { + if source == nil { + return nil + } + + result := &ImageSource{} + if t, ok := source["type"].(string); ok { + result.Type = t + } + if mediaType, ok := source["media_type"].(string); ok { + result.MediaType = mediaType + } + if data, ok := source["data"].(string); ok { + result.Data = data + } + if url, ok := source["url"].(string); ok { + result.URL = url + } + + return result +} + +func extractToolUseBlocks(content []interface{}) []OpenAIToolCall { + var result []OpenAIToolCall + + for _, item := range content { + block := parseContentBlock(item) + if block.Type == "tool_use" { + args, _ := json.Marshal(block.Input) + result = append(result, OpenAIToolCall{ + ID: block.ID, + Type: "function", + Function: OpenAIFunctionCall{ + Name: block.Name, + Arguments: string(args), + }, + }) + } + } + + return result +} + +func extractToolResultContent(content interface{}) string { + switch c := content.(type) { + case string: + return c + case []interface{}: + for _, item := range c { + if block, ok := item.(map[string]interface{}); ok { + if block["type"] == "text" { + if text, ok := block["text"].(string); ok { + return text + } + } + } + } + } + return "" +} + +func mapRole(role string) string { + switch role { + case "user": + return "user" + case "assistant": + return "assistant" + default: + return role + } +} diff --git a/sandbox/proxy/main.go b/sandbox/proxy/main.go new file mode 100644 index 00000000..fc572c0b --- /dev/null +++ b/sandbox/proxy/main.go @@ -0,0 +1,535 @@ +// Package proxy provides a lightweight API proxy that translates +// Anthropic Messages API to OpenAI Chat Completions API. +package proxy + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// Config holds the proxy server configuration +type Config struct { + Port int + Backend string + Model string + APIKey string + Timeout int + Verbose bool + LogFile string +} + +// Server is the API proxy server +type Server struct { + config *Config + client *http.Client +} + +// Main is the entry point for the proxy server +func Main() { + config := parseFlags() + if err := config.Validate(); err != nil { + log.Fatalf("Configuration error: %v", err) + } + + // Setup log file if specified + if config.LogFile != "" { + f, err := os.OpenFile(config.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatalf("Failed to open log file: %v", err) + } + // Write to both file and stdout + mw := io.MultiWriter(os.Stdout, f) + log.SetOutput(mw) + } + + server := NewServer(config) + addr := fmt.Sprintf(":%d", config.Port) + + log.Printf("Claude API Proxy starting on %s", addr) + log.Printf("Backend: %s", config.Backend) + log.Printf("Model: %s", config.Model) + + http.HandleFunc("/v1/messages", server.handleMessages) + http.HandleFunc("/health", server.handleHealth) + + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatalf("Server failed: %v", err) + } +} + +func parseFlags() *Config { + config := &Config{} + + flag.IntVar(&config.Port, "p", 0, "Listen port") + flag.IntVar(&config.Port, "port", 0, "Listen port") + flag.StringVar(&config.Backend, "b", "", "Backend API URL") + flag.StringVar(&config.Backend, "backend", "", "Backend API URL") + flag.StringVar(&config.Model, "m", "", "Backend model name") + flag.StringVar(&config.Model, "model", "", "Backend model name") + flag.StringVar(&config.APIKey, "k", "", "Backend API key") + flag.StringVar(&config.APIKey, "api-key", "", "Backend API key") + flag.IntVar(&config.Timeout, "t", 0, "Request timeout in seconds") + flag.IntVar(&config.Timeout, "timeout", 0, "Request timeout in seconds") + flag.BoolVar(&config.Verbose, "v", false, "Verbose logging") + flag.BoolVar(&config.Verbose, "verbose", false, "Verbose logging") + flag.StringVar(&config.LogFile, "l", "", "Log file path") + flag.StringVar(&config.LogFile, "log", "", "Log file path") + + flag.Parse() + + // Override with environment variables if flags not set + if config.Port == 0 { + if v := os.Getenv("CLAUDE_PROXY_PORT"); v != "" { + config.Port, _ = strconv.Atoi(v) + } + } + if config.Port == 0 { + config.Port = 3456 + } + + if config.Backend == "" { + config.Backend = os.Getenv("CLAUDE_PROXY_BACKEND") + } + + if config.Model == "" { + config.Model = os.Getenv("CLAUDE_PROXY_MODEL") + } + + if config.APIKey == "" { + config.APIKey = os.Getenv("CLAUDE_PROXY_API_KEY") + } + + if config.Timeout == 0 { + if v := os.Getenv("CLAUDE_PROXY_TIMEOUT"); v != "" { + config.Timeout, _ = strconv.Atoi(v) + } + } + if config.Timeout == 0 { + config.Timeout = 300 + } + + return config +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if c.Backend == "" { + return fmt.Errorf("backend URL is required (-b or CLAUDE_PROXY_BACKEND)") + } + if c.Model == "" { + return fmt.Errorf("model name is required (-m or CLAUDE_PROXY_MODEL)") + } + if c.APIKey == "" { + return fmt.Errorf("API key is required (-k or CLAUDE_PROXY_API_KEY)") + } + return nil +} + +// NewServer creates a new proxy server +func NewServer(config *Config) *Server { + return &Server{ + config: config, + client: &http.Client{ + Timeout: time.Duration(config.Timeout) * time.Second, + }, + } +} + +// handleHealth handles health check requests +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// handleMessages handles the /v1/messages endpoint +func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Read request body + body, err := io.ReadAll(r.Body) + if err != nil { + s.errorResponse(w, http.StatusBadRequest, "invalid_request", "Failed to read request body") + return + } + defer r.Body.Close() + + if s.config.Verbose { + log.Printf("Received request: %s", string(body)) + } + + // Parse Anthropic request + var anthropicReq AnthropicRequest + if err := json.Unmarshal(body, &anthropicReq); err != nil { + s.errorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid JSON") + return + } + + // Convert to OpenAI request + openaiReq := s.convertRequest(&anthropicReq) + + // Forward to backend + if anthropicReq.Stream { + s.handleStreamingRequest(w, openaiReq) + } else { + s.handleNonStreamingRequest(w, openaiReq) + } +} + +// handleNonStreamingRequest handles non-streaming requests +func (s *Server) handleNonStreamingRequest(w http.ResponseWriter, openaiReq *OpenAIRequest) { + openaiReq.Stream = false + + resp, err := s.forwardRequest(openaiReq) + if err != nil { + s.errorResponse(w, http.StatusBadGateway, "backend_error", err.Error()) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + s.errorResponse(w, http.StatusBadGateway, "backend_error", "Failed to read backend response") + return + } + + if s.config.Verbose { + log.Printf("Backend response: %s", string(body)) + } + + if resp.StatusCode != http.StatusOK { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) + return + } + + // Parse OpenAI response + var openaiResp OpenAIResponse + if err := json.Unmarshal(body, &openaiResp); err != nil { + s.errorResponse(w, http.StatusBadGateway, "backend_error", "Invalid backend response") + return + } + + // Convert to Anthropic response + anthropicResp := s.convertResponse(&openaiResp) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(anthropicResp) +} + +// handleStreamingRequest handles streaming requests with SSE +func (s *Server) handleStreamingRequest(w http.ResponseWriter, openaiReq *OpenAIRequest) { + openaiReq.Stream = true + openaiReq.StreamOptions = &StreamOptions{IncludeUsage: true} + + resp, err := s.forwardRequest(openaiReq) + if err != nil { + s.errorResponse(w, http.StatusBadGateway, "backend_error", err.Error()) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) + return + } + + // Set SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + if !ok { + s.errorResponse(w, http.StatusInternalServerError, "server_error", "Streaming not supported") + return + } + + // Send message_start event + msgID := generateID("msg_") + startEvent := AnthropicStreamEvent{ + Type: "message_start", + Message: &AnthropicResponse{ + ID: msgID, + Type: "message", + Role: "assistant", + Content: []ContentBlock{}, + Model: s.config.Model, + StopReason: nil, + StopSequence: nil, + Usage: &Usage{InputTokens: 0, OutputTokens: 0}, + }, + } + s.writeSSE(w, flusher, startEvent) + + // Process SSE stream from backend + s.processStream(w, flusher, resp.Body, msgID) +} + +// processStream processes the SSE stream from the backend +func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body io.Reader, msgID string) { + scanner := bufio.NewScanner(body) + // Increase buffer size for large responses + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + + var contentBlockStarted bool + var currentToolCall *ToolCallAccumulator + var toolCalls []*ToolCallAccumulator + var contentIndex int + var finishReason string + + for scanner.Scan() { + line := scanner.Text() + + if !strings.HasPrefix(line, "data: ") { + continue + } + + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var chunk OpenAIStreamChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + if s.config.Verbose { + log.Printf("Failed to parse chunk: %s", data) + } + continue + } + + if len(chunk.Choices) == 0 { + // Usage update at the end + if chunk.Usage != nil { + usageEvent := AnthropicStreamEvent{ + Type: "message_delta", + Delta: &DeltaContent{ + StopReason: &finishReason, + }, + Usage: &Usage{ + InputTokens: chunk.Usage.PromptTokens, + OutputTokens: chunk.Usage.CompletionTokens, + }, + } + s.writeSSE(w, flusher, usageEvent) + } + continue + } + + choice := chunk.Choices[0] + + // Handle finish reason + if choice.FinishReason != "" { + finishReason = mapFinishReason(choice.FinishReason) + } + + // Handle tool calls + if len(choice.Delta.ToolCalls) > 0 { + for _, tc := range choice.Delta.ToolCalls { + if tc.Index != nil { + idx := *tc.Index + // New tool call + if idx >= len(toolCalls) { + // Close previous content block if exists + if contentBlockStarted && currentToolCall == nil { + stopEvent := AnthropicStreamEvent{ + Type: "content_block_stop", + Index: contentIndex - 1, + } + s.writeSSE(w, flusher, stopEvent) + } + + currentToolCall = &ToolCallAccumulator{ + Index: idx, + ID: tc.ID, + Name: tc.Function.Name, + Args: "", + } + toolCalls = append(toolCalls, currentToolCall) + + // Send content_block_start for tool_use + startEvent := AnthropicStreamEvent{ + Type: "content_block_start", + Index: contentIndex, + ContentBlock: &ContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: map[string]interface{}{}, // Required empty object for streaming + }, + } + s.writeSSE(w, flusher, startEvent) + contentIndex++ + } + + // Accumulate arguments + if tc.Function.Arguments != "" { + currentToolCall.Args += tc.Function.Arguments + deltaEvent := AnthropicStreamEvent{ + Type: "content_block_delta", + Index: contentIndex - 1, + Delta: &DeltaContent{ + Type: "input_json_delta", + PartialJSON: tc.Function.Arguments, + }, + } + s.writeSSE(w, flusher, deltaEvent) + } + } + } + continue + } + + // Handle text content + if choice.Delta.Content != "" { + if !contentBlockStarted { + // Send content_block_start + startEvent := AnthropicStreamEvent{ + Type: "content_block_start", + Index: contentIndex, + ContentBlock: &ContentBlock{ + Type: "text", + Text: "", + }, + } + s.writeSSE(w, flusher, startEvent) + contentBlockStarted = true + contentIndex++ + } + + // Send content_block_delta + deltaEvent := AnthropicStreamEvent{ + Type: "content_block_delta", + Index: contentIndex - 1, + Delta: &DeltaContent{ + Type: "text_delta", + Text: choice.Delta.Content, + }, + } + s.writeSSE(w, flusher, deltaEvent) + } + } + + // Close any open content blocks + if contentBlockStarted || len(toolCalls) > 0 { + stopEvent := AnthropicStreamEvent{ + Type: "content_block_stop", + Index: contentIndex - 1, + } + s.writeSSE(w, flusher, stopEvent) + } + + // Send message_delta with stop reason + if finishReason == "" { + finishReason = "end_turn" + } + deltaEvent := AnthropicStreamEvent{ + Type: "message_delta", + Delta: &DeltaContent{ + StopReason: &finishReason, + }, + } + s.writeSSE(w, flusher, deltaEvent) + + // Send message_stop + stopEvent := AnthropicStreamEvent{ + Type: "message_stop", + } + s.writeSSE(w, flusher, stopEvent) +} + +// writeSSE writes an SSE event to the response +func (s *Server) writeSSE(w http.ResponseWriter, flusher http.Flusher, event interface{}) { + data, err := json.Marshal(event) + if err != nil { + return + } + + eventType := "" + if e, ok := event.(AnthropicStreamEvent); ok { + eventType = e.Type + } + + if eventType != "" { + fmt.Fprintf(w, "event: %s\n", eventType) + } + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + + if s.config.Verbose { + log.Printf("SSE event: %s", string(data)) + } +} + +// forwardRequest forwards a request to the backend +func (s *Server) forwardRequest(openaiReq *OpenAIRequest) (*http.Response, error) { + body, err := json.Marshal(openaiReq) + if err != nil { + return nil, err + } + + if s.config.Verbose { + log.Printf("Forwarding to backend: %s", string(body)) + } + + req, err := http.NewRequest(http.MethodPost, s.config.Backend, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.config.APIKey) + + return s.client.Do(req) +} + +// errorResponse sends an error response in Anthropic format +func (s *Server) errorResponse(w http.ResponseWriter, status int, errType, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]interface{}{ + "type": "error", + "error": map[string]string{ + "type": errType, + "message": message, + }, + }) +} + +// generateID generates a unique ID with a prefix +func generateID(prefix string) string { + return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano()) +} + +// mapFinishReason maps OpenAI finish reasons to Anthropic stop reasons +func mapFinishReason(reason string) string { + switch reason { + case "stop": + return "end_turn" + case "length": + return "max_tokens" + case "tool_calls", "function_call": + return "tool_use" + case "content_filter": + return "end_turn" + default: + return "end_turn" + } +} diff --git a/sandbox/proxy/types.go b/sandbox/proxy/types.go new file mode 100644 index 00000000..e5132942 --- /dev/null +++ b/sandbox/proxy/types.go @@ -0,0 +1,243 @@ +package proxy + +// ============================================ +// Anthropic API Types +// ============================================ + +// AnthropicRequest represents a request to the Anthropic Messages API +type AnthropicRequest struct { + Model string `json:"model"` + Messages []AnthropicMsg `json:"messages"` + System interface{} `json:"system,omitempty"` // string or []SystemBlock + MaxTokens int `json:"max_tokens"` + Stream bool `json:"stream,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Tools []AnthropicTool `json:"tools,omitempty"` + ToolChoice *AnthropicToolChoice `json:"tool_choice,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// AnthropicMsg represents a message in Anthropic format +type AnthropicMsg struct { + Role string `json:"role"` + Content interface{} `json:"content"` // string or []ContentBlock +} + +// ContentBlock represents a content block in Anthropic messages +type ContentBlock struct { + Type string `json:"type"` + + // For text blocks + Text string `json:"text,omitempty"` + + // For image blocks + Source *ImageSource `json:"source,omitempty"` + + // For tool_use blocks + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input interface{} `json:"input,omitempty"` + + // For tool_result blocks + ToolUseID string `json:"tool_use_id,omitempty"` + Content interface{} `json:"content,omitempty"` // string or []ContentBlock + IsError bool `json:"is_error,omitempty"` +} + +// ImageSource represents an image source in Anthropic format +type ImageSource struct { + Type string `json:"type"` // "base64" or "url" + MediaType string `json:"media_type,omitempty"` // e.g., "image/jpeg" + Data string `json:"data,omitempty"` // base64 encoded data + URL string `json:"url,omitempty"` // URL for url type +} + +// SystemBlock represents a system message block +type SystemBlock struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// AnthropicTool represents a tool definition in Anthropic format +type AnthropicTool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema interface{} `json:"input_schema"` +} + +// AnthropicToolChoice represents tool choice in Anthropic format +type AnthropicToolChoice struct { + Type string `json:"type"` // "auto", "any", "tool" + Name string `json:"name,omitempty"` +} + +// AnthropicResponse represents a response from the Anthropic Messages API +type AnthropicResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []ContentBlock `json:"content"` + Model string `json:"model"` + StopReason *string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence,omitempty"` + Usage *Usage `json:"usage"` +} + +// Usage represents token usage statistics +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +// AnthropicStreamEvent represents an SSE event in Anthropic format +type AnthropicStreamEvent struct { + Type string `json:"type"` + Index int `json:"index,omitempty"` + Message *AnthropicResponse `json:"message,omitempty"` + ContentBlock *ContentBlock `json:"content_block,omitempty"` + Delta *DeltaContent `json:"delta,omitempty"` + Usage *Usage `json:"usage,omitempty"` +} + +// DeltaContent represents delta content in streaming +type DeltaContent struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + PartialJSON string `json:"partial_json,omitempty"` + StopReason *string `json:"stop_reason,omitempty"` +} + +// ============================================ +// OpenAI API Types +// ============================================ + +// OpenAIRequest represents a request to OpenAI Chat Completions API +type OpenAIRequest struct { + Model string `json:"model"` + Messages []OpenAIMsg `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Stop []string `json:"stop,omitempty"` + Tools []OpenAITool `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,omitempty"` // "auto", "none", "required", or object +} + +// StreamOptions represents stream options in OpenAI format +type StreamOptions struct { + IncludeUsage bool `json:"include_usage"` +} + +// OpenAIMsg represents a message in OpenAI format +type OpenAIMsg struct { + Role string `json:"role"` + Content interface{} `json:"content,omitempty"` // string or []OpenAIContent + ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +// OpenAIContent represents content in OpenAI messages (for multimodal) +type OpenAIContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *OpenAIImageURL `json:"image_url,omitempty"` +} + +// OpenAIImageURL represents an image URL in OpenAI format +type OpenAIImageURL struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` // "auto", "low", "high" +} + +// OpenAITool represents a tool definition in OpenAI format +type OpenAITool struct { + Type string `json:"type"` + Function OpenAIFunction `json:"function"` +} + +// OpenAIFunction represents a function in OpenAI tool +type OpenAIFunction struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters interface{} `json:"parameters"` +} + +// OpenAIToolCall represents a tool call in OpenAI format +type OpenAIToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function OpenAIFunctionCall `json:"function"` + Index *int `json:"index,omitempty"` // For streaming +} + +// OpenAIFunctionCall represents a function call in OpenAI format +type OpenAIFunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// OpenAIResponse represents a response from OpenAI Chat Completions API +type OpenAIResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAIChoice `json:"choices"` + Usage *OpenAIUsage `json:"usage,omitempty"` +} + +// OpenAIChoice represents a choice in OpenAI response +type OpenAIChoice struct { + Index int `json:"index"` + Message OpenAIMsg `json:"message"` + FinishReason string `json:"finish_reason"` +} + +// OpenAIUsage represents usage statistics in OpenAI format +type OpenAIUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// OpenAIStreamChunk represents a streaming chunk from OpenAI +type OpenAIStreamChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAIStreamChoice `json:"choices"` + Usage *OpenAIUsage `json:"usage,omitempty"` +} + +// OpenAIStreamChoice represents a choice in OpenAI streaming response +type OpenAIStreamChoice struct { + Index int `json:"index"` + Delta OpenAIStreamDelta `json:"delta"` + FinishReason string `json:"finish_reason,omitempty"` +} + +// OpenAIStreamDelta represents delta content in OpenAI streaming +type OpenAIStreamDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` +} + +// ============================================ +// Internal Types +// ============================================ + +// ToolCallAccumulator accumulates tool call data during streaming +type ToolCallAccumulator struct { + Index int + ID string + Name string + Args string +}