Replace CCR with claude-proxy for Claude sandbox

- Add claude-proxy: lightweight Go proxy to translate between Anthropic
  and OpenAI-compatible APIs with full streaming and tool-calling support
- Update Dockerfile to include claude-proxy binary (multi-arch)
- Add auto-start proxy via entrypoint when env vars are set
- Update executor.go to write proxy config and start proxy
- Simplify command.go to use direct Claude CLI with proxy
- Support both docker run -e and config file for proxy settings

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Max 2026-01-31 12:44:29 +08:00
parent d07781ceeb
commit 3d16a9de77
10 changed files with 1696 additions and 134 deletions

1
.gitignore vendored
View file

@ -57,3 +57,4 @@ agent/test/UPGRADE_PLAN.md
introduction/*
!sandbox/docker/build.sh
sandbox/docker/yao-bridge-*
sandbox/docker/claude-proxy-*

View file

@ -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)
}

View file

@ -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
}

View file

@ -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"

View file

@ -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"]

214
sandbox/proxy/README.md Normal file
View file

@ -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 <port> Listen port (default: 3456)
-b, --backend <url> Backend API URL (required)
-m, --model <model> Backend model name (required)
-k, --api-key <key> Backend API key (required)
-l, --log <path> Log file path
-t, --timeout <seconds> 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 <container> 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.

View file

@ -0,0 +1,7 @@
package main
import "github.com/yaoapp/yao/sandbox/proxy"
func main() {
proxy.Main()
}

423
sandbox/proxy/convert.go Normal file
View file

@ -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
}
}

535
sandbox/proxy/main.go Normal file
View file

@ -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"
}
}

243
sandbox/proxy/types.go Normal file
View file

@ -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
}