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/*
|
||||
!sandbox/docker/build.sh
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
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