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:
parent
d07781ceeb
commit
3d16a9de77
10 changed files with 1696 additions and 134 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -57,3 +57,4 @@ agent/test/UPGRADE_PLAN.md
|
||||||
introduction/*
|
introduction/*
|
||||||
!sandbox/docker/build.sh
|
!sandbox/docker/build.sh
|
||||||
sandbox/docker/yao-bridge-*
|
sandbox/docker/yao-bridge-*
|
||||||
|
sandbox/docker/claude-proxy-*
|
||||||
|
|
|
||||||
|
|
@ -13,34 +13,33 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map
|
||||||
// Build system prompt from conversation history
|
// Build system prompt from conversation history
|
||||||
systemPrompt, userPrompt := buildPrompts(messages)
|
systemPrompt, userPrompt := buildPrompts(messages)
|
||||||
|
|
||||||
// Build the ccr code command with all arguments
|
// Build Claude CLI arguments
|
||||||
// We use bash -c to ensure CCR is started first, then run ccr code with proper argument handling
|
var claudeArgs []string
|
||||||
var ccrArgs []string
|
|
||||||
|
|
||||||
// Add permission mode (required for MCP tools to work)
|
// Add permission mode (required for MCP tools to work)
|
||||||
permMode := "acceptEdits" // default
|
permMode := "bypassPermissions" // default for sandbox
|
||||||
if opts != nil && opts.Arguments != nil {
|
if opts != nil && opts.Arguments != nil {
|
||||||
if mode, ok := opts.Arguments["permission_mode"].(string); ok && mode != "" {
|
if mode, ok := opts.Arguments["permission_mode"].(string); ok && mode != "" {
|
||||||
permMode = 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
|
// Add MCP config if available
|
||||||
if opts != nil && len(opts.MCPConfig) > 0 {
|
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
|
// 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
|
// Build the full bash command
|
||||||
// Start CCR daemon, wait, then run ccr code with arguments
|
// claude-proxy is already started by prepareEnvironment
|
||||||
bashCmd := "nohup ccr start >/dev/null 2>&1 & sleep 2; ccr code"
|
bashCmd := "claude -p"
|
||||||
for _, arg := range ccrArgs {
|
for _, arg := range claudeArgs {
|
||||||
// Quote arguments that might contain special characters
|
// Quote arguments that might contain special characters
|
||||||
bashCmd += fmt.Sprintf(" %q", arg)
|
bashCmd += fmt.Sprintf(" %q", arg)
|
||||||
}
|
}
|
||||||
bashCmd += " -p"
|
|
||||||
if userPrompt != "" {
|
if userPrompt != "" {
|
||||||
bashCmd += fmt.Sprintf(" %q", userPrompt)
|
bashCmd += fmt.Sprintf(" %q", userPrompt)
|
||||||
}
|
}
|
||||||
|
|
@ -123,20 +122,9 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
// CCR configuration via environment
|
// claude-proxy runs on localhost:3456, Claude CLI connects to it
|
||||||
// CCR (Claude Code Router) transforms OpenAI-compatible API to Anthropic API format
|
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
|
||||||
if opts.ConnectorHost != "" {
|
env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set system prompt via environment (Claude CLI supports this)
|
// Set system prompt via environment (Claude CLI supports this)
|
||||||
if systemPrompt != "" {
|
if systemPrompt != "" {
|
||||||
|
|
@ -150,11 +138,6 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
|
||||||
env["CLAUDE_MAX_TURNS"] = fmt.Sprintf("%v", maxTurns)
|
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)
|
// output_format (default to stream-json for streaming)
|
||||||
if outputFormat, ok := opts.Arguments["output_format"].(string); ok {
|
if outputFormat, ok := opts.Arguments["output_format"].(string); ok {
|
||||||
env["CLAUDE_OUTPUT_FORMAT"] = outputFormat
|
env["CLAUDE_OUTPUT_FORMAT"] = outputFormat
|
||||||
|
|
@ -168,73 +151,30 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildCCRConfig builds the CCR (Claude Code Router) configuration JSON
|
// BuildProxyConfig builds the claude-proxy configuration JSON
|
||||||
// CCR requires a specific format with Providers array and Router configuration
|
// This config file is read by start-claude-proxy script in the container
|
||||||
func BuildCCRConfig(opts *Options) ([]byte, error) {
|
func BuildProxyConfig(opts *Options) ([]byte, error) {
|
||||||
if opts == nil {
|
if opts == nil {
|
||||||
return nil, fmt.Errorf("options is required")
|
return nil, fmt.Errorf("options is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine provider name based on host
|
// Build backend URL - ensure it ends with /chat/completions
|
||||||
providerName := "custom"
|
backendURL := opts.ConnectorHost
|
||||||
apiBaseURL := opts.ConnectorHost
|
if !strings.HasSuffix(backendURL, "/chat/completions") {
|
||||||
needsTransformer := false
|
backendURL = strings.TrimSuffix(backendURL, "/") + "/chat/completions"
|
||||||
|
|
||||||
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 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{}{
|
config := map[string]interface{}{
|
||||||
"LOG": true,
|
"backend": backendURL,
|
||||||
"API_TIMEOUT_MS": 600000,
|
"api_key": opts.ConnectorKey,
|
||||||
"NON_INTERACTIVE_MODE": true,
|
"model": opts.Model,
|
||||||
"Providers": []interface{}{provider},
|
|
||||||
"Router": router,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.MarshalIndent(config, "", " ")
|
return json.MarshalIndent(config, "", " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildCCRConfig is deprecated, kept for backward compatibility
|
||||||
|
// Use BuildProxyConfig instead
|
||||||
|
func BuildCCRConfig(opts *Options) ([]byte, error) {
|
||||||
|
return BuildProxyConfig(opts)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,11 +139,11 @@ func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Mes
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepareEnvironment prepares the container environment before execution
|
// 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 {
|
func (e *Executor) prepareEnvironment(ctx context.Context) error {
|
||||||
// 1. Write CCR config (Claude Code Router configuration)
|
// 1. Write claude-proxy config and start the proxy
|
||||||
if err := e.writeCCRConfig(ctx); err != nil {
|
if err := e.startClaudeProxy(ctx); err != nil {
|
||||||
return fmt.Errorf("failed to write CCR config: %w", err)
|
return fmt.Errorf("failed to start claude-proxy: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Write MCP config if provided
|
// 2. Write MCP config if provided
|
||||||
|
|
@ -165,20 +165,34 @@ func (e *Executor) prepareEnvironment(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeCCRConfig writes the CCR configuration file to the container
|
// startClaudeProxy writes proxy config and starts claude-proxy
|
||||||
func (e *Executor) writeCCRConfig(ctx context.Context) error {
|
func (e *Executor) startClaudeProxy(ctx context.Context) error {
|
||||||
// Build CCR config
|
// Build proxy config
|
||||||
configJSON, err := BuildCCRConfig(e.opts)
|
configJSON, err := BuildProxyConfig(e.opts)
|
||||||
if err != nil {
|
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
|
// Write config to workspace
|
||||||
configPath := "/home/sandbox/.claude-code-router/config.json"
|
configPath := e.workDir + "/.claude-proxy.json"
|
||||||
if err := e.manager.WriteFile(ctx, e.containerName, configPath, configJSON); err != nil {
|
if err := e.manager.WriteFile(ctx, e.containerName, configPath, configJSON); err != nil {
|
||||||
return fmt.Errorf("failed to write config to %s: %w", configPath, err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,21 @@ echo "Built: yao-bridge-amd64, yao-bridge-arm64"
|
||||||
|
|
||||||
cd "$SCRIPT_DIR"
|
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
|
# Check if buildx is available and set up
|
||||||
setup_buildx() {
|
setup_buildx() {
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -141,4 +156,5 @@ docker images | grep -E "(sandbox-base|sandbox-claude|sandbox-cursor)" | head -1
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Cleanup ==="
|
echo "=== Cleanup ==="
|
||||||
rm -f "$SCRIPT_DIR/yao-bridge-amd64" "$SCRIPT_DIR/yao-bridge-arm64"
|
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"
|
echo "Removed temporary binary files"
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Supports both amd64 and arm64 architectures
|
||||||
# Base: Ubuntu 24.04 LTS
|
# Base: Ubuntu 24.04 LTS
|
||||||
ARG REGISTRY=yaoapp
|
ARG REGISTRY=yaoapp
|
||||||
|
|
@ -39,54 +39,223 @@ ENV PATH="/home/sandbox/.npm-global/bin:${PATH}"
|
||||||
RUN npm install -g @anthropic-ai/claude-code || \
|
RUN npm install -g @anthropic-ai/claude-code || \
|
||||||
echo "Claude CLI installation skipped (may not be available yet)"
|
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
|
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
|
#!/bin/bash
|
||||||
# CCR wrapper: starts CCR daemon and runs ccr code
|
# Claude Proxy startup script
|
||||||
# Usage: ccr-run "prompt" or ccr-run -c /path/to/config.json "prompt"
|
# Usage: start-proxy [options]
|
||||||
|
# Options are passed directly to claude-proxy
|
||||||
|
|
||||||
CONFIG=""
|
LOG_DIR="${WORKSPACE:-/workspace}"
|
||||||
while [[ $# -gt 0 ]]; do
|
LOG_FILE="${LOG_DIR}/proxy.log"
|
||||||
case $1 in
|
|
||||||
-c|--config)
|
|
||||||
CONFIG="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Apply config if provided
|
# Ensure log directory exists
|
||||||
if [ -n "$CONFIG" ] && [ -f "$CONFIG" ]; then
|
mkdir -p "$LOG_DIR" 2>/dev/null || true
|
||||||
cp "$CONFIG" ~/.claude-code-router/config.json
|
|
||||||
|
# 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
|
fi
|
||||||
|
|
||||||
# Start CCR daemon (nohup because ccr start -d doesn't work in containers)
|
if [ -z "$CLAUDE_PROXY_API_KEY" ]; then
|
||||||
nohup ccr start >/dev/null 2>&1 &
|
echo "Error: CLAUDE_PROXY_API_KEY is not set"
|
||||||
sleep 2
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Run ccr code
|
if [ -z "$CLAUDE_PROXY_MODEL" ]; then
|
||||||
exec ccr code -p "$*"
|
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
|
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
|
USER sandbox
|
||||||
|
|
||||||
# Verify installations
|
# Verify installations
|
||||||
RUN node --version && npm --version && python3 --version && \
|
RUN node --version && npm --version && python3 --version && \
|
||||||
claude --version || true && \
|
claude --version || true && \
|
||||||
ccr --version || true
|
claude-proxy --help 2>&1 | head -1 || true
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
CMD ["sleep", "infinity"]
|
CMD ["sleep", "infinity"]
|
||||||
|
|
|
||||||
214
sandbox/proxy/README.md
Normal file
214
sandbox/proxy/README.md
Normal 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.
|
||||||
7
sandbox/proxy/cmd/claude-proxy/main.go
Normal file
7
sandbox/proxy/cmd/claude-proxy/main.go
Normal 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
423
sandbox/proxy/convert.go
Normal 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
535
sandbox/proxy/main.go
Normal 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
243
sandbox/proxy/types.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue