Merge pull request #1444 from trheyi/main

Refactor Claude sandbox: replace CCR with claude-proxy and fix streaming issues
This commit is contained in:
Max 2026-01-31 23:00:46 +08:00 committed by GitHub
commit 7857b356d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 4107 additions and 350 deletions

3
.gitignore vendored
View file

@ -57,3 +57,6 @@ 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-*
sandbox/docker/claude/claude-proxy-*
sandbox/proxy/claude-proxy-linux-*

View file

@ -157,10 +157,11 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// Sandbox must be created BEFORE hooks so that hooks can access ctx.sandbox // Sandbox must be created BEFORE hooks so that hooks can access ctx.sandbox
var sandboxExecutor agentsandbox.Executor var sandboxExecutor agentsandbox.Executor
var sandboxCleanup func() var sandboxCleanup func()
var sandboxLoadingMsgID string
if ast.HasSandbox() { if ast.HasSandbox() {
ctx.Logger.Phase("Sandbox") ctx.Logger.Phase("Sandbox")
var err error var err error
sandboxExecutor, sandboxCleanup, err = ast.initSandbox(ctx, opts) sandboxExecutor, sandboxCleanup, sandboxLoadingMsgID, err = ast.initSandbox(ctx, opts)
if err != nil { if err != nil {
ast.traceAgentFail(agentNode, err) ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err) ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
@ -285,7 +286,7 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// Choose between sandbox execution or direct LLM execution // Choose between sandbox execution or direct LLM execution
if ast.HasSandbox() { if ast.HasSandbox() {
// Sandbox execution path (Claude CLI, Cursor CLI, etc.) // Sandbox execution path (Claude CLI, Cursor CLI, etc.)
completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor) completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor, sandboxLoadingMsgID)
} else { } else {
// Direct LLM execution path // Direct LLM execution path
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts) completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)

View file

@ -12,6 +12,7 @@ import (
gouMCP "github.com/yaoapp/gou/mcp" gouMCP "github.com/yaoapp/gou/mcp"
mcpProcess "github.com/yaoapp/gou/mcp/process" mcpProcess "github.com/yaoapp/gou/mcp/process"
"github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
agentsandbox "github.com/yaoapp/yao/agent/sandbox" agentsandbox "github.com/yaoapp/yao/agent/sandbox"
"github.com/yaoapp/yao/config" "github.com/yaoapp/yao/config"
@ -55,22 +56,22 @@ func (ast *Assistant) HasSandbox() bool {
// Returns the full Executor (for LLM calls), cleanup function, and any error // Returns the full Executor (for LLM calls), cleanup function, and any error
// This is called BEFORE hooks so that hooks can access ctx.sandbox // This is called BEFORE hooks so that hooks can access ctx.sandbox
// The executor implements both agentsandbox.Executor and context.SandboxExecutor interfaces // The executor implements both agentsandbox.Executor and context.SandboxExecutor interfaces
func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (agentsandbox.Executor, func(), error) { func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (agentsandbox.Executor, func(), string, error) {
// Get sandbox manager (singleton) // Get sandbox manager (singleton)
manager, err := GetSandboxManager() manager, err := GetSandboxManager()
if err != nil { if err != nil {
ctx.Logger.Error("Sandbox manager initialization failed: %v", err) ctx.Logger.Error("Sandbox manager initialization failed: %v", err)
return nil, nil, fmt.Errorf("sandbox manager not available: %w", err) return nil, nil, "", fmt.Errorf("sandbox manager not available: %w", err)
} }
if manager == nil { if manager == nil {
return nil, nil, fmt.Errorf("sandbox manager not initialized") return nil, nil, "", fmt.Errorf("sandbox manager not initialized")
} }
// Build executor options from assistant config // Build executor options from assistant config
execOpts, err := ast.buildSandboxOptions(ctx, opts) execOpts, err := ast.buildSandboxOptions(ctx, opts)
if err != nil { if err != nil {
ctx.Logger.Error("Failed to build sandbox options: %v", err) ctx.Logger.Error("Failed to build sandbox options: %v", err)
return nil, nil, fmt.Errorf("failed to build sandbox options: %w", err) return nil, nil, "", fmt.Errorf("failed to build sandbox options: %w", err)
} }
// Log sandbox creation // Log sandbox creation
@ -86,7 +87,7 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (
loadingMsg := &message.Message{ loadingMsg := &message.Message{
Type: message.TypeLoading, Type: message.TypeLoading,
Props: map[string]interface{}{ Props: map[string]interface{}{
"message": "Preparing sandbox environment...", "message": i18n.T(ctx.Locale, "sandbox.preparing"),
}, },
} }
loadingMsgID, _ := ctx.SendStream(loadingMsg) loadingMsgID, _ := ctx.SendStream(loadingMsg)
@ -98,11 +99,21 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (
if traceErr == nil && trace != nil { if traceErr == nil && trace != nil {
trace.Error("Sandbox creation failed: %v", err) trace.Error("Sandbox creation failed: %v", err)
} }
// End loading message // End loading message with done:true
if loadingMsgID != "" { if loadingMsgID != "" {
ctx.End(loadingMsgID) doneMsg := &message.Message{
MessageID: loadingMsgID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": i18n.T(ctx.Locale, "sandbox.failed"),
"done": true,
},
} }
return nil, nil, fmt.Errorf("failed to create sandbox executor: %w", err) ctx.Send(doneMsg)
}
return nil, nil, "", fmt.Errorf("failed to create sandbox executor: %w", err)
} }
// Log sandbox ready // Log sandbox ready
@ -111,11 +122,6 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (
trace.Info("Sandbox container ready") trace.Info("Sandbox container ready")
} }
// End loading message
if loadingMsgID != "" {
ctx.End(loadingMsgID)
}
// Return cleanup function // Return cleanup function
cleanup := func() { cleanup := func() {
if err := executor.Close(); err != nil { if err := executor.Close(); err != nil {
@ -123,7 +129,9 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (
} }
} }
return executor, cleanup, nil // Keep loadingMsgID open - it will be closed when first output is received
// This provides better UX: user sees "Preparing..." until actual content appears
return executor, cleanup, loadingMsgID, nil
} }
// executeSandboxStream executes the request using sandbox (Claude CLI, etc.) // executeSandboxStream executes the request using sandbox (Claude CLI, etc.)
@ -135,6 +143,7 @@ func (ast *Assistant) executeSandboxStream(
agentNode traceTypes.Node, agentNode traceTypes.Node,
streamHandler message.StreamFunc, streamHandler message.StreamFunc,
executor agentsandbox.Executor, executor agentsandbox.Executor,
loadingMsgID string,
) (*context.CompletionResponse, error) { ) (*context.CompletionResponse, error) {
// Mark the agentNode as used to avoid unused variable error // Mark the agentNode as used to avoid unused variable error
@ -147,9 +156,41 @@ func (ast *Assistant) executeSandboxStream(
// Log sandbox execution // Log sandbox execution
ctx.Logger.Info("Executing via sandbox (command: %s)", ast.Sandbox.Command) ctx.Logger.Info("Executing via sandbox (command: %s)", ast.Sandbox.Command)
// Pass the "preparing sandbox" loading message ID to executor
// It will be closed when first output (text or tool) is received
if loadingMsgID != "" {
executor.SetLoadingMsgID(loadingMsgID)
}
// Execute LLM call via sandbox // Execute LLM call via sandbox
// The loadingMsgID will be closed when first output is received
// Tool calls will create their own loading messages below the text
resp, err := executor.Stream(ctx, completionMessages, streamHandler) resp, err := executor.Stream(ctx, completionMessages, streamHandler)
if err != nil { if err != nil {
// Close loading message on error
if loadingMsgID != "" {
doneMsg := &message.Message{
MessageID: loadingMsgID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": i18n.T(ctx.Locale, "sandbox.failed"),
"done": true,
},
}
ctx.Send(doneMsg)
}
// Send error message to client
errMsg := &message.Message{
Type: message.TypeError,
Props: map[string]interface{}{
"message": err.Error(),
},
}
ctx.Send(errMsg)
return nil, fmt.Errorf("sandbox execution failed: %w", err) return nil, fmt.Errorf("sandbox execution failed: %w", err)
} }
@ -198,6 +239,18 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op
} }
} }
// Check if assistant has prompts (from prompts.yml)
// If prompts are configured, we need to call Claude CLI
if len(ast.Prompts) > 0 {
// Extract system prompt from prompts
for _, prompt := range ast.Prompts {
if prompt.Role == "system" && prompt.Content != "" {
execOpts.SystemPrompt = prompt.Content
break
}
}
}
// Resolve connector settings // Resolve connector settings
conn, _, err := ast.GetConnector(ctx, opts) conn, _, err := ast.GetConnector(ctx, opts)
if err != nil { if err != nil {
@ -215,6 +268,40 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op
execOpts.Model = model execOpts.Model = model
} }
// Extract extra connector options (thinking, max_tokens, temperature, etc.)
// These are backend-specific parameters that need to be passed through to the proxy
connectorOptions := make(map[string]interface{})
for k, v := range setting {
// Skip standard fields that are already handled
switch k {
case "host", "key", "model", "azure", "capabilities":
continue
default:
// Include all other fields as extra options
connectorOptions[k] = v
}
}
if len(connectorOptions) > 0 {
execOpts.ConnectorOptions = connectorOptions
ctx.Logger.Debug("Connector options extracted: %v", connectorOptions)
}
// Extract secrets from sandbox config (e.g., GITHUB_TOKEN: "$ENV.GITHUB_TOKEN")
if ast.Sandbox != nil && len(ast.Sandbox.Secrets) > 0 {
secrets := make(map[string]string)
for k, v := range ast.Sandbox.Secrets {
// Resolve $ENV.XXX references
resolved := resolveEnvValue(v)
if resolved != "" {
secrets[k] = resolved
}
}
if len(secrets) > 0 {
execOpts.Secrets = secrets
ctx.Logger.Debug("Secrets extracted: %d items", len(secrets))
}
}
// Build MCP config and load tools if the assistant has MCP servers configured // Build MCP config and load tools if the assistant has MCP servers configured
if ast.MCP != nil && len(ast.MCP.Servers) > 0 { if ast.MCP != nil && len(ast.MCP.Servers) > 0 {
// Build MCP config for Claude CLI // Build MCP config for Claude CLI
@ -354,3 +441,21 @@ func (ast *Assistant) BuildMCPConfigForSandbox(ctx *context.Context) ([]byte, er
return json.Marshal(config) return json.Marshal(config)
} }
// resolveEnvValue resolves environment variable references in a string
// Supports format: $ENV.VAR_NAME or plain value
// Returns empty string if the variable is not set
func resolveEnvValue(value string) string {
if value == "" {
return ""
}
// Check for $ENV.XXX format
if len(value) > 5 && value[:5] == "$ENV." {
envName := value[5:]
return os.Getenv(envName)
}
// Return as-is if not an env reference
return value
}

View file

@ -78,28 +78,35 @@ func TestClaudeCommandBuilding(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Verify command structure // Verify command structure
// Command is now: ["bash", "-c", "nohup ccr start ... && ccr code ..."] // Command is now: ["bash", "-c", "cat << 'INPUTEOF' | claude -p ... INPUTEOF"]
assert.NotEmpty(t, cmd) assert.NotEmpty(t, cmd)
assert.Equal(t, "bash", cmd[0], "Command should start with bash") assert.Equal(t, "bash", cmd[0], "Command should start with bash")
assert.Equal(t, "-c", cmd[1], "Second arg should be -c") assert.Equal(t, "-c", cmd[1], "Second arg should be -c")
assert.Contains(t, cmd[2], "ccr code", "Bash command should contain ccr code") assert.Contains(t, cmd[2], "claude -p", "Bash command should contain claude -p")
assert.Contains(t, cmd[2], "--permission-mode", "Should include permission mode") assert.Contains(t, cmd[2], "--permission-mode", "Should include permission mode")
assert.Contains(t, cmd[2], "--input-format", "Should include input-format flag")
assert.Contains(t, cmd[2], "--output-format", "Should include output-format flag")
assert.Contains(t, cmd[2], "--verbose", "Should include verbose flag")
assert.Contains(t, cmd[2], "stream-json", "Should use stream-json format")
assert.Contains(t, cmd[2], "INPUTEOF", "Should use heredoc for input")
t.Logf("Built command: %v", cmd) t.Logf("Built command: %v", cmd)
// Verify environment variables // Verify environment variables (claude-proxy mode)
assert.NotEmpty(t, env) assert.NotEmpty(t, env)
assert.Equal(t, "https://ark.cn-beijing.volces.com/api/v3", env["CCR_API_BASE"]) assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"], "Should set proxy base URL")
assert.Equal(t, "test-api-key", env["CCR_API_KEY"]) assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"], "Should set dummy API key for proxy")
assert.Equal(t, "ep-xxxxx", env["CCR_MODEL"])
assert.Equal(t, "10", env["CLAUDE_MAX_TURNS"]) // max_turns is passed via CLI flag
assert.Equal(t, "acceptEdits", env["CLAUDE_PERMISSION_MODE"]) // system prompt is written to file via heredoc, then referenced via --append-system-prompt-file
assert.Equal(t, "stream-json", env["CLAUDE_OUTPUT_FORMAT"]) assert.Contains(t, cmd[2], "--max-turns", "Should include max-turns flag")
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a helpful coding assistant") assert.Contains(t, cmd[2], "cat << 'PROMPTEOF' > /tmp/.system-prompt.txt", "Should use heredoc for system prompt")
assert.Contains(t, cmd[2], "--append-system-prompt-file", "Should include append-system-prompt-file flag")
assert.Contains(t, cmd[2], "You are a helpful coding assistant", "Command should contain system prompt")
t.Logf("Built environment: %v", env) t.Logf("Built environment: %v", env)
} }
// TestClaudeCCRConfigBuilding tests that CCR config is correctly built // TestClaudeProxyConfigBuilding tests that claude-proxy config is correctly built
func TestClaudeCCRConfigBuilding(t *testing.T) { func TestClaudeProxyConfigBuilding(t *testing.T) {
test.Prepare(t, config.Conf) test.Prepare(t, config.Conf)
defer test.Clean() defer test.Clean()
@ -109,20 +116,20 @@ func TestClaudeCCRConfigBuilding(t *testing.T) {
Model: "ep-xxxxx", Model: "ep-xxxxx",
} }
configJSON, err := claude.BuildCCRConfig(opts) configJSON, err := claude.BuildProxyConfig(opts)
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, configJSON) require.NotEmpty(t, configJSON)
t.Logf("CCR config: %s", string(configJSON)) t.Logf("Proxy config: %s", string(configJSON))
// Verify the JSON contains expected fields (CCR uses snake_case) // Verify the JSON contains expected fields for claude-proxy
assert.Contains(t, string(configJSON), "api_base_url") assert.Contains(t, string(configJSON), "backend")
assert.Contains(t, string(configJSON), "api_key") assert.Contains(t, string(configJSON), "api_key")
assert.Contains(t, string(configJSON), "models") assert.Contains(t, string(configJSON), "model")
// Verify CCR format fields assert.Contains(t, string(configJSON), "test-api-key")
assert.Contains(t, string(configJSON), "Providers") assert.Contains(t, string(configJSON), "ep-xxxxx")
assert.Contains(t, string(configJSON), "Router") // Backend URL should end with /chat/completions
assert.Contains(t, string(configJSON), "volcengine") assert.Contains(t, string(configJSON), "/chat/completions")
} }
// TestDefaultImageSelection tests that default images are correctly selected // TestDefaultImageSelection tests that default images are correctly selected

View file

@ -0,0 +1,335 @@
package caller_test
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/caller"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// TestSandboxE2E_ClaudeCLIExecution tests the full sandbox + claude-proxy integration
// This test verifies:
// 1. Assistant loads with sandbox and prompts configured
// 2. Claude CLI is invoked (not skipped) because prompts exist
// 3. claude-proxy correctly translates requests to OpenAI backend
// 4. Response is received with actual content
func TestSandboxE2E_ClaudeCLIExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox E2E test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the e2e-test assistant
ast, err := assistant.Get("tests.sandbox.e2e-test")
if err != nil {
t.Skipf("Skipping test: e2e-test assistant not available: %v", err)
}
// Verify configuration
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
require.NotEmpty(t, ast.Prompts, "Prompts should be configured (required for Claude CLI)")
t.Logf("✓ Assistant loaded: sandbox=%s, prompts=%d", ast.Sandbox.Command, len(ast.Prompts))
// Create authorized info
authorized := &types.AuthorizedInfo{
Subject: "sandbox-e2e-test",
UserID: "e2e-user-123",
TenantID: "e2e-tenant",
}
// Create context with unique chat ID
chatID := "sandbox-e2e-" + time.Now().Format("20060102-150405")
ctx := agentContext.New(context.Background(), authorized, chatID)
ctx.AssistantID = "tests.sandbox.e2e-test"
// Create JSAPI
api := caller.NewJSAPI(ctx)
// Test 1: Simple echo command
t.Run("EchoCommand", func(t *testing.T) {
messages := []interface{}{
map[string]interface{}{
"role": "user",
"content": "Run this command: echo 'SANDBOX_E2E_SUCCESS_12345'",
},
}
opts := map[string]interface{}{
"skip": map[string]interface{}{
"history": true,
},
}
startTime := time.Now()
result := api.Call("tests.sandbox.e2e-test", messages, opts)
duration := time.Since(startTime)
t.Logf("Execution time: %v", duration)
require.NotNil(t, result, "Result should not be nil")
r, ok := result.(*caller.Result)
require.True(t, ok, "Result should be *caller.Result")
// Check for errors
if r.Error != "" {
// Check if it's a Docker/sandbox availability issue
if strings.Contains(r.Error, "Docker") ||
strings.Contains(r.Error, "sandbox") ||
strings.Contains(r.Error, "container") {
t.Skipf("Skipping: Docker/sandbox not available: %s", r.Error)
}
t.Fatalf("Agent call failed: %s", r.Error)
}
// Verify response
t.Logf("Response content: %s", truncateStr(r.Content, 500))
assert.NotEmpty(t, r.Content, "Response content should not be empty")
// Check if Claude executed the command
if strings.Contains(r.Content, "SANDBOX_E2E_SUCCESS_12345") {
t.Log("✓ Echo command executed successfully - found verification string")
} else if strings.Contains(strings.ToLower(r.Content), "echo") ||
strings.Contains(r.Content, "SANDBOX") {
t.Log("✓ Response mentions the command or partial output")
} else {
t.Log("⚠ Response does not contain expected output")
}
})
}
// TestSandboxE2E_FileCreation tests that Claude can create files in the sandbox
func TestSandboxE2E_FileCreation(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox E2E test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the e2e-test assistant
ast, err := assistant.Get("tests.sandbox.e2e-test")
if err != nil {
t.Skipf("Skipping test: e2e-test assistant not available: %v", err)
}
require.NotNil(t, ast.Sandbox)
require.NotEmpty(t, ast.Prompts)
// Create context
authorized := &types.AuthorizedInfo{
Subject: "sandbox-e2e-test",
UserID: "e2e-user-456",
TenantID: "e2e-tenant",
}
chatID := "sandbox-file-" + time.Now().Format("20060102-150405")
ctx := agentContext.New(context.Background(), authorized, chatID)
ctx.AssistantID = "tests.sandbox.e2e-test"
api := caller.NewJSAPI(ctx)
messages := []interface{}{
map[string]interface{}{
"role": "user",
"content": "Create a file named 'test-output.txt' with the content 'FILE_CREATION_VERIFIED_67890', then read it back and show me the content.",
},
}
opts := map[string]interface{}{
"skip": map[string]interface{}{
"history": true,
},
}
startTime := time.Now()
result := api.Call("tests.sandbox.e2e-test", messages, opts)
duration := time.Since(startTime)
t.Logf("Execution time: %v", duration)
require.NotNil(t, result)
r, ok := result.(*caller.Result)
require.True(t, ok)
if r.Error != "" {
if strings.Contains(r.Error, "Docker") ||
strings.Contains(r.Error, "sandbox") {
t.Skipf("Skipping: Docker/sandbox not available: %s", r.Error)
}
t.Fatalf("Agent call failed: %s", r.Error)
}
t.Logf("Response: %s", truncateStr(r.Content, 800))
// Verify file was created and read back
if strings.Contains(r.Content, "FILE_CREATION_VERIFIED_67890") {
t.Log("✓ File creation and read verified")
} else if strings.Contains(strings.ToLower(r.Content), "created") ||
strings.Contains(strings.ToLower(r.Content), "wrote") ||
strings.Contains(r.Content, "test-output.txt") {
t.Log("✓ File operation appears successful")
} else {
t.Log("⚠ Could not verify file creation")
}
}
// TestSandboxE2E_HookOnlyMode tests that hooks can work without Claude CLI
func TestSandboxE2E_HookOnlyMode(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox E2E test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the hook-only assistant (no prompts)
ast, err := assistant.Get("tests.sandbox.hook-only")
if err != nil {
t.Skipf("Skipping test: hook-only assistant not available: %v", err)
}
// Verify configuration - no prompts means Claude CLI should be skipped
require.NotNil(t, ast.Sandbox)
require.Empty(t, ast.Prompts, "Hook-only mode should have no prompts")
t.Logf("✓ Hook-only assistant loaded: sandbox=%s, prompts=%d (should be 0)", ast.Sandbox.Command, len(ast.Prompts))
// Create context
authorized := &types.AuthorizedInfo{
Subject: "sandbox-hook-test",
UserID: "hook-user-789",
TenantID: "hook-tenant",
}
chatID := "sandbox-hook-" + time.Now().Format("20060102-150405")
ctx := agentContext.New(context.Background(), authorized, chatID)
ctx.AssistantID = "tests.sandbox.hook-only"
api := caller.NewJSAPI(ctx)
messages := []interface{}{
map[string]interface{}{
"role": "user",
"content": "test hook-only mode",
},
}
opts := map[string]interface{}{
"skip": map[string]interface{}{
"history": true,
},
}
startTime := time.Now()
result := api.Call("tests.sandbox.hook-only", messages, opts)
duration := time.Since(startTime)
t.Logf("Execution time: %v", duration)
require.NotNil(t, result)
r, ok := result.(*caller.Result)
require.True(t, ok)
if r.Error != "" {
if strings.Contains(r.Error, "Docker") ||
strings.Contains(r.Error, "sandbox") {
t.Skipf("Skipping: Docker/sandbox not available: %s", r.Error)
}
t.Fatalf("Agent call failed: %s", r.Error)
}
t.Logf("Response: %s", r.Content)
t.Log("✓ Hook-only mode executed successfully")
}
// TestSandboxE2E_StreamingResponse verifies streaming works correctly
func TestSandboxE2E_StreamingResponse(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox E2E test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the e2e-test assistant
ast, err := assistant.Get("tests.sandbox.e2e-test")
if err != nil {
t.Skipf("Skipping test: e2e-test assistant not available: %v", err)
}
require.NotNil(t, ast.Sandbox)
require.NotEmpty(t, ast.Prompts)
// Create context
authorized := &types.AuthorizedInfo{
Subject: "sandbox-stream-test",
UserID: "stream-user",
TenantID: "stream-tenant",
}
chatID := "sandbox-stream-" + time.Now().Format("20060102-150405")
ctx := agentContext.New(context.Background(), authorized, chatID)
ctx.AssistantID = "tests.sandbox.e2e-test"
api := caller.NewJSAPI(ctx)
// Ask for a slightly longer response to verify streaming
messages := []interface{}{
map[string]interface{}{
"role": "user",
"content": "Say 'Hello World' and nothing else.",
},
}
opts := map[string]interface{}{
"skip": map[string]interface{}{
"history": true,
},
}
startTime := time.Now()
result := api.Call("tests.sandbox.e2e-test", messages, opts)
duration := time.Since(startTime)
t.Logf("Execution time: %v", duration)
require.NotNil(t, result)
r, ok := result.(*caller.Result)
require.True(t, ok)
if r.Error != "" {
if strings.Contains(r.Error, "Docker") ||
strings.Contains(r.Error, "sandbox") {
t.Skipf("Skipping: Docker/sandbox not available: %s", r.Error)
}
t.Fatalf("Agent call failed: %s", r.Error)
}
t.Logf("Response: %s", r.Content)
// Verify we got a response
assert.NotEmpty(t, r.Content, "Should have response content")
if strings.Contains(strings.ToLower(r.Content), "hello") {
t.Log("✓ Streaming response received with expected content")
} else {
t.Log("✓ Streaming response received")
}
}
func truncateStr(s string, maxLen int) string {
s = strings.ReplaceAll(s, "\n", " ")
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View file

@ -99,14 +99,39 @@ func init() {
"kb.chat.name": "Chat Knowledge Base", "kb.chat.name": "Chat Knowledge Base",
"kb.chat.description": "Auto-created knowledge base collection for chat sessions", "kb.chat.description": "Auto-created knowledge base collection for chat sessions",
// Sandbox: assistant/sandbox.go - Sandbox status messages
"sandbox.preparing": "Preparing sandbox environment",
"sandbox.ready": "Sandbox ready",
"sandbox.working": "Working on your request",
"sandbox.completed": "Completed",
"sandbox.failed": "Execution failed",
// Sandbox: claude/executor.go - Tool execution messages
"sandbox.tool.read": "Reading file",
"sandbox.tool.write": "Writing file",
"sandbox.tool.edit": "Editing file",
"sandbox.tool.bash": "Running command",
"sandbox.tool.glob": "Finding files",
"sandbox.tool.grep": "Searching code",
"sandbox.tool.ls": "Listing directory",
"sandbox.tool.task": "Running subtask",
"sandbox.tool.web_search": "Searching web",
"sandbox.tool.web_fetch": "Fetching URL",
"sandbox.tool.todo_write": "Managing tasks",
"sandbox.tool.ask_question": "Asking question",
"sandbox.tool.switch_mode": "Switching mode",
"sandbox.tool.read_lints": "Checking lints",
"sandbox.tool.edit_notebook": "Editing notebook",
"sandbox.tool.unknown": "Executing {{name}}",
// Content: content/image/image.go - Image processing messages // Content: content/image/image.go - Image processing messages
"content.image.analyzing": "Analyzing image...", "content.image.analyzing": "Analyzing image",
// Content: content/pdf/pdf.go - PDF processing messages // Content: content/pdf/pdf.go - PDF processing messages
"content.pdf.analyzing_page": "Analyzing PDF page %d/%d...", "content.pdf.analyzing_page": "Analyzing PDF page %d/%d",
// Search: assistant/search.go - Output messages // Search: assistant/search.go - Output messages
"search.loading": "Searching...", "search.loading": "Searching",
"search.success": "Found %d references", "search.success": "Found %d references",
"search.success.one": "Found 1 reference", "search.success.one": "Found 1 reference",
"search.partial": "Found %d references (some sources failed)", "search.partial": "Found %d references (some sources failed)",
@ -114,12 +139,12 @@ func init() {
"search.no_results": "No references found", "search.no_results": "No references found",
// Search Intent: assistant/search.go - Intent detection messages // Search Intent: assistant/search.go - Intent detection messages
"search.intent.loading": "Checking if references are needed...", "search.intent.loading": "Checking if references are needed",
"search.intent.need_search": "Searching for references...", "search.intent.need_search": "Searching for references",
"search.intent.no_search": "No references needed", "search.intent.no_search": "No references needed",
// Keyword Extraction: assistant/search.go - Keyword extraction messages // Keyword Extraction: assistant/search.go - Keyword extraction messages
"search.keyword.loading": "Analyzing conversation...", "search.keyword.loading": "Analyzing conversation",
"search.keyword.done": "Analysis complete", "search.keyword.done": "Analysis complete",
// Search: assistant/search.go - Trace labels // Search: assistant/search.go - Trace labels
@ -198,14 +223,39 @@ func init() {
"kb.chat.name": "聊天知识库", "kb.chat.name": "聊天知识库",
"kb.chat.description": "自动为聊天会话创建的知识库集合", "kb.chat.description": "自动为聊天会话创建的知识库集合",
// Sandbox: assistant/sandbox.go - Sandbox status messages
"sandbox.preparing": "正在准备沙箱环境",
"sandbox.ready": "沙箱环境就绪",
"sandbox.working": "正在处理您的请求",
"sandbox.completed": "处理完成",
"sandbox.failed": "执行失败",
// Sandbox: claude/executor.go - Tool execution messages
"sandbox.tool.read": "正在读取文件",
"sandbox.tool.write": "正在写入文件",
"sandbox.tool.edit": "正在编辑文件",
"sandbox.tool.bash": "正在执行命令",
"sandbox.tool.glob": "正在查找文件",
"sandbox.tool.grep": "正在搜索代码",
"sandbox.tool.ls": "正在列出目录",
"sandbox.tool.task": "正在执行子任务",
"sandbox.tool.web_search": "正在搜索网页",
"sandbox.tool.web_fetch": "正在获取网页",
"sandbox.tool.todo_write": "正在管理任务",
"sandbox.tool.ask_question": "正在询问问题",
"sandbox.tool.switch_mode": "正在切换模式",
"sandbox.tool.read_lints": "正在检查代码",
"sandbox.tool.edit_notebook": "正在编辑笔记本",
"sandbox.tool.unknown": "正在执行 {{name}}",
// Content: content/image/image.go - Image processing messages // Content: content/image/image.go - Image processing messages
"content.image.analyzing": "正在分析图片...", "content.image.analyzing": "正在分析图片",
// Content: content/pdf/pdf.go - PDF processing messages // Content: content/pdf/pdf.go - PDF processing messages
"content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页...", "content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页",
// Search: assistant/search.go - Output messages // Search: assistant/search.go - Output messages
"search.loading": "正在搜索...", "search.loading": "正在搜索",
"search.success": "找到 %d 条参考资料", "search.success": "找到 %d 条参考资料",
"search.success.one": "找到 1 条参考资料", "search.success.one": "找到 1 条参考资料",
"search.partial": "找到 %d 条参考资料(部分来源失败)", "search.partial": "找到 %d 条参考资料(部分来源失败)",
@ -213,12 +263,12 @@ func init() {
"search.no_results": "未找到相关资料", "search.no_results": "未找到相关资料",
// Search Intent: assistant/search.go - Intent detection messages // Search Intent: assistant/search.go - Intent detection messages
"search.intent.loading": "检查是否需要查询资料...", "search.intent.loading": "检查是否需要查询资料",
"search.intent.need_search": "正在查询相关资料...", "search.intent.need_search": "正在查询相关资料",
"search.intent.no_search": "无需查询资料", "search.intent.no_search": "无需查询资料",
// Keyword Extraction: assistant/search.go - Keyword extraction messages // Keyword Extraction: assistant/search.go - Keyword extraction messages
"search.keyword.loading": "正在分析对话内容...", "search.keyword.loading": "正在分析对话内容",
"search.keyword.done": "分析完成", "search.keyword.done": "分析完成",
// Search: assistant/search.go - Trace labels // Search: assistant/search.go - Trace labels
@ -325,14 +375,39 @@ func init() {
"kb.chat.name": "聊天知识库", "kb.chat.name": "聊天知识库",
"kb.chat.description": "自动为聊天会话创建的知识库集合", "kb.chat.description": "自动为聊天会话创建的知识库集合",
// Sandbox: assistant/sandbox.go - Sandbox status messages
"sandbox.preparing": "正在准备沙箱环境",
"sandbox.ready": "沙箱环境就绪",
"sandbox.working": "正在处理您的请求",
"sandbox.completed": "处理完成",
"sandbox.failed": "执行失败",
// Sandbox: claude/executor.go - Tool execution messages
"sandbox.tool.read": "正在读取文件",
"sandbox.tool.write": "正在写入文件",
"sandbox.tool.edit": "正在编辑文件",
"sandbox.tool.bash": "正在执行命令",
"sandbox.tool.glob": "正在查找文件",
"sandbox.tool.grep": "正在搜索代码",
"sandbox.tool.ls": "正在列出目录",
"sandbox.tool.task": "正在执行子任务",
"sandbox.tool.web_search": "正在搜索网页",
"sandbox.tool.web_fetch": "正在获取网页",
"sandbox.tool.todo_write": "正在管理任务",
"sandbox.tool.ask_question": "正在询问问题",
"sandbox.tool.switch_mode": "正在切换模式",
"sandbox.tool.read_lints": "正在检查代码",
"sandbox.tool.edit_notebook": "正在编辑笔记本",
"sandbox.tool.unknown": "正在执行 {{name}}",
// Content: content/image/image.go - Image processing messages // Content: content/image/image.go - Image processing messages
"content.image.analyzing": "正在分析图片...", "content.image.analyzing": "正在分析图片",
// Content: content/pdf/pdf.go - PDF processing messages // Content: content/pdf/pdf.go - PDF processing messages
"content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页...", "content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页",
// Search: assistant/search.go - Output messages // Search: assistant/search.go - Output messages
"search.loading": "正在搜索...", "search.loading": "正在搜索",
"search.success": "找到 %d 条参考资料", "search.success": "找到 %d 条参考资料",
"search.success.one": "找到 1 条参考资料", "search.success.one": "找到 1 条参考资料",
"search.partial": "找到 %d 条参考资料(部分来源失败)", "search.partial": "找到 %d 条参考资料(部分来源失败)",
@ -340,12 +415,12 @@ func init() {
"search.no_results": "未找到相关资料", "search.no_results": "未找到相关资料",
// Search Intent: assistant/search.go - Intent detection messages // Search Intent: assistant/search.go - Intent detection messages
"search.intent.loading": "检查是否需要查询资料...", "search.intent.loading": "检查是否需要查询资料",
"search.intent.need_search": "正在查询相关资料...", "search.intent.need_search": "正在查询相关资料",
"search.intent.no_search": "无需查询资料", "search.intent.no_search": "无需查询资料",
// Keyword Extraction: assistant/search.go - Keyword extraction messages // Keyword Extraction: assistant/search.go - Keyword extraction messages
"search.keyword.loading": "正在分析对话内容...", "search.keyword.loading": "正在分析对话内容",
"search.keyword.done": "分析完成", "search.keyword.done": "分析完成",
// Search: assistant/search.go - Trace labels // Search: assistant/search.go - Trace labels

View file

@ -8,44 +8,117 @@ import (
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
) )
// sandboxEnvPrompt is the system prompt injected for sandbox environment
// This tells Claude CLI about the workspace and project structure
const sandboxEnvPrompt = `## Sandbox Environment
You are running in a sandboxed environment with the following setup:
- **Working Directory**: /workspace
- **Project Structure**: If this is a new project, create a dedicated project folder (e.g., /workspace/my-project/) and work inside it
- **File Access**: You have full read/write access to /workspace
- **Output Files**: Save all output files to the working directory
When creating new projects:
1. Create a project directory with a descriptive name
2. Initialize the project structure inside that directory
3. Keep all related files organized within the project folder
## IMPORTANT: Restricted Tools
The following tools are NOT available in this environment and you must NOT use them:
- EnterPlanMode, ExitPlanMode (use regular text to explain plans instead)
- Task, TaskOutput, TaskStop (complete tasks directly without delegation)
- AskUserQuestion (make reasonable assumptions instead of asking)
- Skill, ToolSearch (not supported)
Focus on using the core tools: Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch.
## GitHub CLI (gh) Usage
When working with GitHub and a token is provided:
1. First authenticate gh CLI using the token: echo "TOKEN" | gh auth login --with-token
2. Then use gh commands normally (gh repo create, gh pr create, etc.)
3. Do NOT use curl to call GitHub API directly - always prefer gh CLI
`
// BuildCommand builds the Claude CLI command and environment variables // BuildCommand builds the Claude CLI command and environment variables
// Uses stdin with --input-format stream-json for unlimited prompt length
func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) { func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) {
// Build system prompt from conversation history // Build system prompt from conversation history
systemPrompt, userPrompt := buildPrompts(messages) systemPrompt, _ := buildPrompts(messages)
// Build the ccr code command with all arguments // Inject sandbox environment prompt
// We use bash -c to ensure CCR is started first, then run ccr code with proper argument handling if systemPrompt != "" {
var ccrArgs []string systemPrompt = systemPrompt + "\n\n" + sandboxEnvPrompt
} else {
systemPrompt = sandboxEnvPrompt
}
// Build input JSONL for Claude CLI (stream-json format)
inputJSONL, err := BuildInputJSONL(messages)
if err != nil {
return nil, nil, fmt.Errorf("failed to build input JSONL: %w", err)
}
// Build Claude CLI arguments
var claudeArgs []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 streaming format flags (required for proper streaming output)
claudeArgs = append(claudeArgs, "--input-format", "stream-json")
claudeArgs = append(claudeArgs, "--output-format", "stream-json")
claudeArgs = append(claudeArgs, "--include-partial-messages") // Enable realtime streaming
claudeArgs = append(claudeArgs, "--verbose")
// Add max_turns if specified
if opts != nil && opts.Arguments != nil {
if maxTurns, ok := opts.Arguments["max_turns"]; ok {
claudeArgs = append(claudeArgs, "--max-turns", fmt.Sprintf("%v", maxTurns))
}
}
// 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 // Use heredoc for both system prompt and input JSONL to avoid shell escaping issues
bashCmd := "nohup ccr start >/dev/null 2>&1 & sleep 2; ccr code" // System prompt may contain quotes, newlines, special characters that break shell quoting
for _, arg := range ccrArgs { var bashCmd strings.Builder
// Quote arguments that might contain special characters
bashCmd += fmt.Sprintf(" %q", arg) // If we have a system prompt, write it to a temp file via heredoc first
} // then use --append-system-prompt-file
bashCmd += " -p" if systemPrompt != "" {
if userPrompt != "" { bashCmd.WriteString("cat << 'PROMPTEOF' > /tmp/.system-prompt.txt\n")
bashCmd += fmt.Sprintf(" %q", userPrompt) bashCmd.WriteString(systemPrompt)
bashCmd.WriteString("\nPROMPTEOF\n")
claudeArgs = append(claudeArgs, "--append-system-prompt-file", "/tmp/.system-prompt.txt")
} }
cmd := []string{"bash", "-c", bashCmd} // Build claude command with all arguments
bashCmd.WriteString("cat << 'INPUTEOF' | claude -p")
for _, arg := range claudeArgs {
// Quote arguments that might contain special characters
bashCmd.WriteString(fmt.Sprintf(" %q", arg))
}
bashCmd.WriteString("\n")
bashCmd.WriteString(string(inputJSONL))
bashCmd.WriteString("\nINPUTEOF")
cmd := []string{"bash", "-c", bashCmd.String()}
// Build environment variables // Build environment variables
env := buildEnvironment(opts, systemPrompt) env := buildEnvironment(opts, systemPrompt)
@ -53,6 +126,44 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map
return cmd, env, nil return cmd, env, nil
} }
// BuildInputJSONL converts messages to Claude CLI stream-json input format
// Each message becomes a line in JSONL format
func BuildInputJSONL(messages []agentContext.Message) ([]byte, error) {
var lines []string
for _, msg := range messages {
// Skip system messages (handled via --system-prompt or env var)
if msg.Role == "system" {
continue
}
// Build the message content
var content interface{}
if msg.Content != nil {
content = msg.Content
} else {
content = ""
}
// Create stream-json message
streamMsg := map[string]interface{}{
"type": msg.Role, // "user" or "assistant"
"message": map[string]interface{}{
"role": msg.Role,
"content": content,
},
}
jsonBytes, err := json.Marshal(streamMsg)
if err != nil {
return nil, fmt.Errorf("failed to marshal message: %w", err)
}
lines = append(lines, string(jsonBytes))
}
return []byte(strings.Join(lines, "\n")), nil
}
// buildPrompts extracts system prompt and user prompt from messages // buildPrompts extracts system prompt and user prompt from messages
func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) { func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) {
var systemParts []string var systemParts []string
@ -123,118 +234,54 @@ 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 != "" { // Note: System prompt and max_turns are passed via CLI flags in BuildCommand
env["CCR_API_KEY"] = opts.ConnectorKey // CLAUDE_SYSTEM_PROMPT environment variable is NOT supported by Claude CLI
} // --append-system-prompt or --system-prompt flags must be used instead
if opts.Model != "" {
env["CCR_MODEL"] = opts.Model
}
// Set system prompt via environment (Claude CLI supports this)
if systemPrompt != "" {
env["CLAUDE_SYSTEM_PROMPT"] = systemPrompt
}
// Additional Claude CLI options from Arguments
if opts.Arguments != nil {
// max_turns
if maxTurns, ok := opts.Arguments["max_turns"]; ok {
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
} else {
env["CLAUDE_OUTPUT_FORMAT"] = "stream-json"
}
} else {
env["CLAUDE_OUTPUT_FORMAT"] = "stream-json"
}
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) { // Config is written to /tmp/.yao/proxy.json (not /workspace/) for security
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,
// Add extra connector options if present (e.g., thinking, max_tokens, temperature)
// These will be passed to the proxy via CLAUDE_PROXY_OPTIONS environment variable
if len(opts.ConnectorOptions) > 0 {
config["options"] = opts.ConnectorOptions
}
// Add secrets if present (e.g., GITHUB_TOKEN, AWS_ACCESS_KEY)
// These will be exported as environment variables for Claude CLI to use
if len(opts.Secrets) > 0 {
config["secrets"] = opts.Secrets
} }
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)
}

View file

@ -1,6 +1,7 @@
package claude package claude
import ( import (
"encoding/json"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -24,16 +25,21 @@ func TestBuildCommand(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Verify command structure // Verify command structure
// Command is now: ["bash", "-c", "nohup ccr start ... ; ccr code ... -p \"prompt\""] // Command is now: ["bash", "-c", "cat << 'INPUTEOF' | claude -p ... INPUTEOF"]
assert.Equal(t, "bash", cmd[0]) assert.Equal(t, "bash", cmd[0])
assert.Equal(t, "-c", cmd[1]) assert.Equal(t, "-c", cmd[1])
assert.Contains(t, cmd[2], "Hello") // User prompt should be in bash command // User message should be in bash command (as JSONL via stdin)
assert.Contains(t, cmd[2], "Hello")
// Should have stream-json flags
assert.Contains(t, cmd[2], "--input-format")
assert.Contains(t, cmd[2], "--output-format")
assert.Contains(t, cmd[2], "--include-partial-messages")
assert.Contains(t, cmd[2], "--verbose")
assert.Contains(t, cmd[2], "stream-json")
// Verify environment variables // Verify environment variables (claude-proxy)
assert.Equal(t, "https://api.example.com", env["CCR_API_BASE"]) assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"])
assert.Equal(t, "key123", env["CCR_API_KEY"]) assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"])
assert.Equal(t, "test-model", env["CCR_MODEL"])
assert.Equal(t, "stream-json", env["CLAUDE_OUTPUT_FORMAT"])
} }
func TestBuildCommandWithSystemPrompt(t *testing.T) { func TestBuildCommandWithSystemPrompt(t *testing.T) {
@ -46,12 +52,35 @@ func TestBuildCommandWithSystemPrompt(t *testing.T) {
opts := &Options{} opts := &Options{}
_, env, err := BuildCommand(messages, opts) cmd, _, err := BuildCommand(messages, opts)
require.NoError(t, err) require.NoError(t, err)
// System prompt should include conversation history // System prompt should be written to file via heredoc, then passed via --append-system-prompt-file
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a code reviewer") bashCmd := cmd[2] // The bash -c command string
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "Conversation History") assert.Contains(t, bashCmd, "cat << 'PROMPTEOF' > /tmp/.system-prompt.txt")
assert.Contains(t, bashCmd, "You are a code reviewer")
assert.Contains(t, bashCmd, "PROMPTEOF")
assert.Contains(t, bashCmd, "--append-system-prompt-file")
assert.Contains(t, bashCmd, "/tmp/.system-prompt.txt")
}
func TestBuildCommandWithSpecialCharsInPrompt(t *testing.T) {
// Test that special characters in prompts are handled correctly
messages := []agentContext.Message{
{Role: "system", Content: "You are a helper.\n\n## Rules\n- Rule 1: Don't use \"quotes\" wrongly\n- Rule 2: Handle 'single quotes' too\n- Rule 3: Special chars like $VAR and `backticks`"},
{Role: "user", Content: "Hello"},
}
opts := &Options{}
cmd, _, err := BuildCommand(messages, opts)
require.NoError(t, err)
bashCmd := cmd[2]
// The heredoc approach should preserve all special characters
assert.Contains(t, bashCmd, "## Rules")
assert.Contains(t, bashCmd, `Don't use "quotes" wrongly`)
assert.Contains(t, bashCmd, "'single quotes'")
} }
func TestBuildCommandWithArguments(t *testing.T) { func TestBuildCommandWithArguments(t *testing.T) {
@ -63,59 +92,115 @@ func TestBuildCommandWithArguments(t *testing.T) {
Arguments: map[string]interface{}{ Arguments: map[string]interface{}{
"max_turns": 20, "max_turns": 20,
"permission_mode": "acceptEdits", "permission_mode": "acceptEdits",
"output_format": "json",
}, },
} }
_, env, err := BuildCommand(messages, opts) cmd, _, err := BuildCommand(messages, opts)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "20", env["CLAUDE_MAX_TURNS"]) bashCmd := cmd[2] // The bash -c command string
assert.Equal(t, "acceptEdits", env["CLAUDE_PERMISSION_MODE"]) // max_turns should be in command args via --max-turns
assert.Equal(t, "json", env["CLAUDE_OUTPUT_FORMAT"]) assert.Contains(t, bashCmd, "--max-turns")
assert.Contains(t, bashCmd, "20")
// permission_mode should be in command args
assert.Contains(t, bashCmd, "acceptEdits")
} }
func TestBuildCCRConfig(t *testing.T) { func TestBuildProxyConfig(t *testing.T) {
opts := &Options{ opts := &Options{
ConnectorHost: "https://api.example.com", ConnectorHost: "https://api.example.com",
ConnectorKey: "key123", ConnectorKey: "key123",
Model: "test-model", Model: "test-model",
} }
configJSON, err := BuildCCRConfig(opts) configJSON, err := BuildProxyConfig(opts)
require.NoError(t, err) require.NoError(t, err)
configStr := string(configJSON) configStr := string(configJSON)
// CCR config uses snake_case for fields // Proxy config uses simple format
assert.Contains(t, configStr, "api_base_url") assert.Contains(t, configStr, "backend")
assert.Contains(t, configStr, "https://api.example.com") assert.Contains(t, configStr, "https://api.example.com/chat/completions")
assert.Contains(t, configStr, "api_key") assert.Contains(t, configStr, "api_key")
assert.Contains(t, configStr, "key123") assert.Contains(t, configStr, "key123")
assert.Contains(t, configStr, "models") assert.Contains(t, configStr, "model")
assert.Contains(t, configStr, "test-model") assert.Contains(t, configStr, "test-model")
// Verify new CCR format fields
assert.Contains(t, configStr, "Providers")
assert.Contains(t, configStr, "Router")
assert.Contains(t, configStr, "NON_INTERACTIVE_MODE")
} }
func TestBuildCCRConfigVolcengine(t *testing.T) { func TestBuildProxyConfigVolcengine(t *testing.T) {
opts := &Options{ opts := &Options{
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3/", ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3/",
ConnectorKey: "test-key", ConnectorKey: "test-key",
Model: "ep-xxx", Model: "ep-xxx",
} }
configJSON, err := BuildCCRConfig(opts) configJSON, err := BuildProxyConfig(opts)
require.NoError(t, err) require.NoError(t, err)
configStr := string(configJSON) configStr := string(configJSON)
// Verify volcengine-specific configuration
assert.Contains(t, configStr, "volcengine")
assert.Contains(t, configStr, "transformer")
assert.Contains(t, configStr, "maxtoken")
// URL should end with /chat/completions // URL should end with /chat/completions
assert.Contains(t, configStr, "/chat/completions") assert.Contains(t, configStr, "/chat/completions")
assert.Contains(t, configStr, "ep-xxx")
}
func TestBuildInputJSONL(t *testing.T) {
messages := []agentContext.Message{
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "Hi there!"},
{Role: "user", Content: "How are you?"},
}
jsonl, err := BuildInputJSONL(messages)
require.NoError(t, err)
// Should not contain system messages (handled separately)
assert.NotContains(t, string(jsonl), "You are helpful")
// Should contain user and assistant messages
assert.Contains(t, string(jsonl), "Hello")
assert.Contains(t, string(jsonl), "Hi there!")
assert.Contains(t, string(jsonl), "How are you?")
// Verify JSONL format (each line is valid JSON)
lines := splitLines(string(jsonl))
for _, line := range lines {
if line == "" {
continue
}
var msg map[string]interface{}
err := json.Unmarshal([]byte(line), &msg)
assert.NoError(t, err, "Line should be valid JSON: %s", line)
assert.Contains(t, msg, "type")
assert.Contains(t, msg, "message")
}
}
func TestBuildInputJSONLMultimodal(t *testing.T) {
// Test with multimodal content (image)
messages := []agentContext.Message{
{
Role: "user",
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "What's in this image?"},
map[string]interface{}{
"type": "image",
"source": map[string]interface{}{
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgo=",
},
},
},
},
}
jsonl, err := BuildInputJSONL(messages)
require.NoError(t, err)
// Should contain the multimodal content
assert.Contains(t, string(jsonl), "What's in this image?")
assert.Contains(t, string(jsonl), "image")
assert.Contains(t, string(jsonl), "base64")
} }
func TestGetMessageContent(t *testing.T) { func TestGetMessageContent(t *testing.T) {
@ -137,3 +222,19 @@ func TestGetMessageContent(t *testing.T) {
assert.Contains(t, getMessageContent(msg3), "Part 1") assert.Contains(t, getMessageContent(msg3), "Part 1")
assert.Contains(t, getMessageContent(msg3), "Part 2") assert.Contains(t, getMessageContent(msg3), "Part 2")
} }
// Helper to split lines
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}

View file

@ -0,0 +1,275 @@
package claude
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/config"
infraSandbox "github.com/yaoapp/yao/sandbox"
"github.com/yaoapp/yao/test"
)
// TestE2ESkipClaudeCLI verifies that Claude CLI is skipped when no prompts/skills/mcp
// This is the "hook-only" mode where hooks take full control
func TestE2ESkipClaudeCLI(t *testing.T) {
if testing.Short() {
t.Skip("Skipping E2E test in short mode")
}
test.Prepare(t, config.Conf)
defer test.Clean()
manager := createTestManager(t)
if manager == nil {
return
}
defer manager.Close()
// Create options WITHOUT SystemPrompt, SkillsDir, or MCPConfig
// This should trigger the skip logic
opts := &Options{
Command: "claude",
Image: "alpine:latest", // Use alpine since we're not calling Claude CLI
UserID: "test-user",
ChatID: fmt.Sprintf("test-e2e-skip-%d", time.Now().UnixNano()),
ConnectorHost: "",
ConnectorKey: "",
Model: "",
// No SystemPrompt, SkillsDir, or MCPConfig
}
exec, err := NewExecutor(manager, opts)
require.NoError(t, err)
defer exec.Close()
// Verify shouldSkipClaudeCLI returns true
assert.True(t, exec.shouldSkipClaudeCLI(), "Should skip Claude CLI when no prompts/skills/mcp")
// Execute Stream - it should return immediately without calling Claude CLI
ctx := agentContext.New(context.Background(), nil, opts.ChatID)
messages := []agentContext.Message{
{Role: "user", Content: "Hello"},
}
response, err := exec.Stream(ctx, messages, nil)
require.NoError(t, err, "Stream should succeed")
require.NotNil(t, response, "Response should not be nil")
// Verify response indicates skip
assert.Contains(t, response.ID, "sandbox-skip", "Response ID should indicate skip")
assert.Equal(t, "sandbox", response.Model, "Model should be 'sandbox' for skip mode")
assert.Empty(t, response.Content, "Content should be empty for skip mode")
t.Log("✓ Claude CLI skip mode verified")
}
// TestE2EExecuteClaudeCLI verifies that Claude CLI is called when prompts are configured
// This requires the real yaoapp/sandbox-claude image and a valid connector
func TestE2EExecuteClaudeCLI(t *testing.T) {
if testing.Short() {
t.Skip("Skipping E2E test in short mode")
}
// Check for required environment variables
apiKey := os.Getenv("DEEPSEEK_API_KEY")
apiProxy := os.Getenv("DEEPSEEK_API_PROXY")
model := os.Getenv("DEEPSEEK_MODELS_V3")
if apiKey == "" || apiProxy == "" || model == "" {
t.Skip("Skipping test: DEEPSEEK_API_KEY, DEEPSEEK_API_PROXY, or DEEPSEEK_MODELS_V3 not set")
}
test.Prepare(t, config.Conf)
defer test.Clean()
// Get data root from environment
dataRoot := os.Getenv("YAO_ROOT")
if dataRoot == "" {
t.Skip("Skipping test: YAO_ROOT not set")
}
// Create config with proper paths
cfg := infraSandbox.DefaultConfig()
cfg.Init(dataRoot)
manager, err := infraSandbox.NewManager(cfg)
if err != nil {
t.Skipf("Skipping test: Docker not available: %v", err)
}
defer manager.Close()
// Create options WITH SystemPrompt (triggers Claude CLI execution)
opts := &Options{
Command: "claude",
Image: "yaoapp/sandbox-claude:latest",
UserID: "test-user",
ChatID: fmt.Sprintf("test-e2e-exec-%d", time.Now().UnixNano()),
ConnectorHost: apiProxy,
ConnectorKey: apiKey,
Model: model,
SystemPrompt: "You are a helpful assistant. Keep responses brief.",
Timeout: 5 * time.Minute,
}
exec, err := NewExecutor(manager, opts)
if err != nil {
t.Skipf("Skipping test: Failed to create executor: %v", err)
}
defer exec.Close()
// Verify shouldSkipClaudeCLI returns false
assert.False(t, exec.shouldSkipClaudeCLI(), "Should NOT skip Claude CLI when prompts are configured")
// Execute Stream with a simple prompt
ctx := agentContext.New(context.Background(), nil, opts.ChatID)
messages := []agentContext.Message{
{Role: "user", Content: "Reply with exactly: TEST_SUCCESS"},
}
// Collect streaming output
var streamedContent strings.Builder
streamHandler := func(chunkType message.StreamChunkType, data []byte) int {
if chunkType == message.ChunkText {
streamedContent.Write(data)
}
return 0 // continue streaming
}
t.Log("Executing Claude CLI with real API call...")
startTime := time.Now()
response, err := exec.Stream(ctx, messages, streamHandler)
duration := time.Since(startTime)
t.Logf("Execution took: %v", duration)
if err != nil {
t.Logf("Stream error (might be expected if Docker/API issue): %v", err)
t.Skipf("Skipping assertion: %v", err)
}
require.NotNil(t, response, "Response should not be nil")
// Log response details
t.Logf("Response ID: %s", response.ID)
t.Logf("Response Model: %s", response.Model)
t.Logf("Response Content: %v", response.Content)
t.Logf("Streamed Content: %s", streamedContent.String())
// Verify we got some response
var fullResponse string
if content, ok := response.Content.(string); ok {
fullResponse = content
}
if fullResponse == "" {
fullResponse = streamedContent.String()
}
if fullResponse != "" {
t.Logf("✓ Claude CLI executed successfully with response: %s", truncate(fullResponse, 200))
} else {
t.Log("⚠ Empty response (Claude CLI might have issues)")
}
}
// TestE2EBuildInputJSONLIntegration tests the full flow of building input JSONL
func TestE2EBuildInputJSONLIntegration(t *testing.T) {
// Test with conversation history
messages := []agentContext.Message{
{Role: "system", Content: "You are a helpful assistant"},
{Role: "user", Content: "What is 2+2?"},
{Role: "assistant", Content: "4"},
{Role: "user", Content: "What about 3+3?"},
}
jsonl, err := BuildInputJSONL(messages)
require.NoError(t, err)
t.Logf("Input JSONL:\n%s", string(jsonl))
// Verify format
lines := strings.Split(string(jsonl), "\n")
assert.GreaterOrEqual(t, len(lines), 3, "Should have at least 3 lines (user, assistant, user)")
// System message should NOT be in JSONL
assert.NotContains(t, string(jsonl), "You are a helpful assistant", "System message should not be in JSONL")
// User and assistant messages should be present
assert.Contains(t, string(jsonl), "What is 2+2", "First user message should be present")
assert.Contains(t, string(jsonl), "4", "Assistant response should be present")
assert.Contains(t, string(jsonl), "What about 3+3", "Second user message should be present")
t.Log("✓ Input JSONL format verified")
}
// TestE2EBuildCommand tests the full command building
func TestE2EBuildCommand(t *testing.T) {
messages := []agentContext.Message{
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Hello"},
}
opts := &Options{
ConnectorHost: "https://api.example.com",
ConnectorKey: "test-key",
Model: "test-model",
Arguments: map[string]interface{}{
"permission_mode": "bypassPermissions",
},
MCPConfig: []byte(`{"mcpServers":{}}`),
}
cmd, env, err := BuildCommand(messages, opts)
require.NoError(t, err)
t.Logf("Command: %v", cmd)
t.Logf("Environment: %v", env)
// Verify command structure
assert.Equal(t, "bash", cmd[0])
assert.Equal(t, "-c", cmd[1])
bashCmd := cmd[2]
// Should use heredoc with INPUTEOF
assert.Contains(t, bashCmd, "cat << 'INPUTEOF'", "Should use heredoc")
assert.Contains(t, bashCmd, "INPUTEOF", "Should have INPUTEOF delimiter")
// Should have streaming flags
assert.Contains(t, bashCmd, "--input-format", "Should have input-format flag")
assert.Contains(t, bashCmd, "--output-format", "Should have output-format flag")
assert.Contains(t, bashCmd, "--verbose", "Should have verbose flag")
assert.Contains(t, bashCmd, "stream-json", "Should use stream-json format")
// Should have permission flags
assert.Contains(t, bashCmd, "--dangerously-skip-permissions", "Should have skip-permissions flag")
assert.Contains(t, bashCmd, "--permission-mode", "Should have permission-mode flag")
assert.Contains(t, bashCmd, "bypassPermissions", "Should have bypassPermissions value")
// Should have MCP config
assert.Contains(t, bashCmd, "--mcp-config", "Should have mcp-config flag")
// Environment should have proxy settings
assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"])
assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"])
// System prompt should be passed via CLI argument, not environment variable
// CLAUDE_SYSTEM_PROMPT env var is NOT supported by Claude CLI
assert.Contains(t, bashCmd, "--append-system-prompt", "Should have append-system-prompt flag")
assert.Contains(t, bashCmd, "You are helpful", "System prompt should be in CLI args")
t.Log("✓ Command building verified")
}
// Helper function to truncate strings
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

View file

@ -6,10 +6,15 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
goujson "github.com/yaoapp/gou/json"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
infraSandbox "github.com/yaoapp/yao/sandbox" infraSandbox "github.com/yaoapp/yao/sandbox"
"github.com/yaoapp/yao/sandbox/ipc" "github.com/yaoapp/yao/sandbox/ipc"
@ -28,9 +33,12 @@ type Options struct {
MCPConfig []byte MCPConfig []byte
MCPTools map[string]*ipc.MCPTool // MCP tools to expose via IPC MCPTools map[string]*ipc.MCPTool // MCP tools to expose via IPC
SkillsDir string SkillsDir string
SystemPrompt string // System prompt from assistant prompts.yml
ConnectorHost string ConnectorHost string
ConnectorKey string ConnectorKey string
Model string Model string
ConnectorOptions map[string]interface{} // Extra connector options (e.g., thinking, max_tokens)
Secrets map[string]string // Secrets to pass to container (e.g., GITHUB_TOKEN)
} }
// Executor implements the sandbox.Executor interface for Claude CLI // Executor implements the sandbox.Executor interface for Claude CLI
@ -39,6 +47,7 @@ type Executor struct {
containerName string containerName string
opts *Options opts *Options
workDir string workDir string
loadingMsgID string // Loading message ID for tool execution updates
} }
// NewExecutor creates a new Claude executor // NewExecutor creates a new Claude executor
@ -90,12 +99,61 @@ func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, er
}, nil }, nil
} }
// SetLoadingMsgID sets the loading message ID for tool execution updates
func (e *Executor) SetLoadingMsgID(id string) {
e.loadingMsgID = id
}
// Stream runs the Claude CLI with streaming output // Stream runs the Claude CLI with streaming output
func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) { func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) {
stdCtx := context.Background() // Create a cancellable context for this stream operation
if ctx != nil && ctx.Context != nil { // We need to handle both:
stdCtx = ctx.Context // 1. HTTP context cancellation (client disconnect)
// 2. InterruptController cancellation (user clicks "stop" button)
//
// Note on InterruptController:
// - ctx.Interrupt.Context() is only cancelled when InterruptForce && len(Messages) == 0
// - When user sends messages with the interrupt, the context is NOT cancelled
// - We use ctx.Interrupt.IsInterrupted() to check for any interrupt signal
stdCtx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
// Start a goroutine to monitor for interrupts and HTTP context cancellation
go func() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-stdCtx.Done():
// Already cancelled, exit
return
case <-ticker.C:
// Check if there's a pending interrupt signal using Peek()
// This works even when Messages are included (which doesn't cancel the context)
if ctx != nil && ctx.Interrupt != nil {
if signal := ctx.Interrupt.Peek(); signal != nil {
cancelFunc()
return
} }
}
// Check InterruptController.IsInterrupted() (for context-cancelled interrupts)
if ctx != nil && ctx.Interrupt != nil && ctx.Interrupt.IsInterrupted() {
cancelFunc()
return
}
// Check HTTP context
if ctx != nil && ctx.Context != nil {
select {
case <-ctx.Context.Done():
cancelFunc()
return
default:
}
}
}
}
}()
// Set MCP tools for this request (dynamic, runtime configuration) // Set MCP tools for this request (dynamic, runtime configuration)
if len(e.opts.MCPTools) > 0 { if len(e.opts.MCPTools) > 0 {
@ -112,6 +170,20 @@ func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Mes
return nil, fmt.Errorf("failed to prepare environment: %w", err) return nil, fmt.Errorf("failed to prepare environment: %w", err)
} }
// Check if we should skip Claude CLI execution
// Skip if no prompts, no skills, and no MCP config
if e.shouldSkipClaudeCLI() {
// Return empty response - hooks can use sandbox API to do their work
return &agentContext.CompletionResponse{
ID: fmt.Sprintf("sandbox-skip-%d", time.Now().UnixNano()),
Model: "sandbox",
Created: time.Now().Unix(),
Role: "assistant",
Content: "",
FinishReason: agentContext.FinishReasonStop,
}, nil
}
// Build Claude CLI command using stored options // Build Claude CLI command using stored options
cmd, env, err := BuildCommand(messages, e.opts) cmd, env, err := BuildCommand(messages, e.opts)
if err != nil { if err != nil {
@ -132,18 +204,70 @@ func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Mes
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to execute command: %w", err) return nil, fmt.Errorf("failed to execute command: %w", err)
} }
defer reader.Close()
// Parse streaming output // Ensure reader is closed when context is cancelled or function returns
return e.parseStream(reader, handler) // This is important for cleanup when user clicks "stop"
done := make(chan struct{})
defer func() {
close(done)
reader.Close()
}()
// Monitor for context cancellation and forcefully kill Claude CLI process
go func() {
// Also start a ticker to periodically check context status for debugging
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-stdCtx.Done():
// First, kill the Claude CLI process inside the container
// This is important because closing the reader/connection alone may not stop the process
// Use a background context since stdCtx is already cancelled
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Kill claude process (the Claude CLI binary)
e.manager.KillProcess(killCtx, e.containerName, "claude")
// Also close the reader to unblock any pending reads
reader.Close()
return
case <-done:
// Normal completion, nothing to do
return
case <-ticker.C:
// Periodic check - no action needed
}
}
}()
// DEBUG: Tee the reader to write raw output to a log file for debugging
debugLogPath := e.workDir + "/claude-cli-raw.log"
debugReader := e.createDebugReader(stdCtx, reader, debugLogPath)
// Parse streaming output (uses e.loadingMsgID set via SetLoadingMsgID)
return e.parseStream(ctx, debugReader, handler)
}
// shouldSkipClaudeCLI checks if Claude CLI execution should be skipped
// Skip when: no system prompt, no skills, and no MCP config
func (e *Executor) shouldSkipClaudeCLI() bool {
hasPrompts := e.opts.SystemPrompt != ""
hasSkills := e.opts.SkillsDir != ""
hasMCP := len(e.opts.MCPConfig) > 0
// If any of these are present, execute Claude CLI
return !hasPrompts && !hasSkills && !hasMCP
} }
// 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 +289,55 @@ 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 // Skip if no connector configured (e.g., test containers without claude-proxy)
configJSON, err := BuildCCRConfig(e.opts) if e.opts.ConnectorHost == "" || e.opts.ConnectorKey == "" {
if err != nil { return nil
return fmt.Errorf("failed to build CCR config: %w", err)
} }
// Write config to container's CCR directory // Build proxy config
configPath := "/home/sandbox/.claude-code-router/config.json" configJSON, err := BuildProxyConfig(e.opts)
if err != nil {
return fmt.Errorf("failed to build proxy config: %w", err)
}
// Create config directory (outside workspace for security - user can't see api_key/secrets)
// /tmp/.yao/ is not visible to user's file manager
configDir := "/tmp/.yao"
if _, err := e.manager.Exec(ctx, e.containerName, []string{"mkdir", "-p", configDir}, nil); err != nil {
return fmt.Errorf("failed to create config directory %s: %w", configDir, err)
}
// Write config to secure location (not in /workspace/)
configPath := configDir + "/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 (only if start-claude-proxy exists in the image)
result, err := e.manager.Exec(ctx, e.containerName, []string{"which", "start-claude-proxy"}, &infraSandbox.ExecOptions{
WorkDir: e.workDir,
})
if err != nil || result.ExitCode != 0 {
// start-claude-proxy not available (e.g., alpine test image), skip
return nil
}
// 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
} }
@ -229,8 +388,62 @@ func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Me
return e.Stream(ctx, messages, nil) return e.Stream(ctx, messages, nil)
} }
// parseStream parses Claude CLI streaming output // debugWriter wraps an io.Reader to write all data to a debug log file
func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) { type debugWriter struct {
reader io.Reader
logFile *os.File
buffer []byte
}
func (d *debugWriter) Read(p []byte) (n int, err error) {
n, err = d.reader.Read(p)
if n > 0 && d.logFile != nil {
// Write raw bytes to log file
d.logFile.Write(p[:n])
d.logFile.Sync()
}
return n, err
}
func (d *debugWriter) Close() error {
if d.logFile != nil {
d.logFile.Close()
}
return nil
}
// createDebugReader creates a tee reader that writes to a debug log file
// The log file is written to the container's workspace for inspection
func (e *Executor) createDebugReader(ctx context.Context, reader io.ReadCloser, logPath string) io.Reader {
// Create a local temp file for debug logging
// We write to a local file first, then copy to container when done
localLogPath := "/tmp/claude-cli-debug-" + e.containerName + ".log"
logFile, err := os.Create(localLogPath)
if err != nil {
return reader
}
// Write header
logFile.WriteString("=== Claude CLI Raw Output Debug Log ===\n")
logFile.WriteString(fmt.Sprintf("Container: %s\n", e.containerName))
logFile.WriteString(fmt.Sprintf("Time: %s\n", time.Now().Format(time.RFC3339)))
logFile.WriteString(fmt.Sprintf("WorkDir: %s\n", e.workDir))
logFile.WriteString("=== BEGIN OUTPUT ===\n")
logFile.Sync()
return &debugWriter{
reader: reader,
logFile: logFile,
}
}
// parseStream parses Claude CLI streaming output (stream-json format)
// Claude CLI output format with --include-partial-messages:
// - {"type":"system","subtype":"init",...} - initialization
// - {"type":"stream_event","event":{"delta":{"type":"text_delta","text":"..."}}} - real-time text deltas
// - {"type":"assistant","message":{...,"content":[{"type":"text","text":"..."}],...}} - complete messages
// - {"type":"result","subtype":"success",...,"result":"..."} - final result
func (e *Executor) parseStream(ctx *agentContext.Context, reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) {
scanner := bufio.NewScanner(reader) scanner := bufio.NewScanner(reader)
// Increase buffer size for potentially large outputs // Increase buffer size for potentially large outputs
buf := make([]byte, 0, 64*1024) buf := make([]byte, 0, 64*1024)
@ -240,18 +453,65 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
var toolCalls []agentContext.ToolCall var toolCalls []agentContext.ToolCall
var model string var model string
var usage *message.UsageInfo var usage *message.UsageInfo
var finalResult string
messageStarted := false // Track if we've sent ChunkMessageStart
prepLoadingClosed := false // Track if "preparing sandbox" loading has been closed
// Tool input accumulation state
type toolState struct {
name string
index int
inputJSON strings.Builder
loadingID string // Each tool has its own loading message
}
var currentTool *toolState
var lastToolLoadingID string // Track the last tool loading ID to close it
// Helper function to close "preparing sandbox" loading on first output
closePrepLoading := func() {
if !prepLoadingClosed && e.loadingMsgID != "" && ctx != nil {
doneMsg := &message.Message{
MessageID: e.loadingMsgID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": "",
"done": true,
},
}
ctx.Send(doneMsg)
prepLoadingClosed = true
}
}
lineCount := 0
// Get the underlying context for cancellation checks
var stdCtx context.Context
if ctx != nil && ctx.Context != nil {
stdCtx = ctx.Context
} else {
stdCtx = context.Background()
}
for scanner.Scan() { for scanner.Scan() {
// Check for context cancellation on each iteration
select {
case <-stdCtx.Done():
return nil, stdCtx.Err()
default:
// Continue processing
}
line := scanner.Text() line := scanner.Text()
lineCount++
if line == "" { if line == "" {
continue continue
} }
// Note: Docker stream demuxing is handled by sandbox.Manager.Stream()
// which uses stdcopy.StdCopy to properly separate stdout/stderr
// Try to parse as JSON (Claude CLI --output-format stream-json) // Try to parse as JSON (Claude CLI --output-format stream-json)
var msg StreamMessage var msg map[string]interface{}
if err := json.Unmarshal([]byte(line), &msg); err != nil { if err := json.Unmarshal([]byte(line), &msg); err != nil {
// Not JSON, might be plain text output // Not JSON, might be plain text output
textContent.WriteString(line) textContent.WriteString(line)
@ -259,24 +519,248 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
continue continue
} }
// Process different message types msgType, _ := msg["type"].(string)
switch msg.Type {
// Process Claude CLI stream-json message types
switch msgType {
case "system":
// Initialization message - extract model if available
if m, ok := msg["model"].(string); ok {
model = m
}
case "stream_event":
// Real-time streaming event (from --include-partial-messages)
// Format: {"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"..."}}}
if event, ok := msg["event"].(map[string]interface{}); ok {
eventType, _ := event["type"].(string)
switch eventType {
case "content_block_start":
// Check if this is a tool_use block starting
// Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"...","name":"Write","input":{}}}}
if contentBlock, ok := event["content_block"].(map[string]interface{}); ok {
blockType, _ := contentBlock["type"].(string)
if blockType == "tool_use" {
toolName, _ := contentBlock["name"].(string)
blockIndex := 0
if idx, ok := event["index"].(float64); ok {
blockIndex = int(idx)
}
if toolName != "" && ctx != nil {
// Close "preparing sandbox" loading on first tool
closePrepLoading()
// Close previous tool loading if exists
if lastToolLoadingID != "" {
doneMsg := &message.Message{
MessageID: lastToolLoadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": "",
"done": true,
},
}
ctx.Send(doneMsg)
}
// Create new loading message for this tool
locale := ctx.Locale
toolLoadingMsg := &message.Message{
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": getToolDescription(toolName, locale),
},
}
newLoadingID, _ := ctx.SendStream(toolLoadingMsg)
// Initialize tool state for input accumulation
currentTool = &toolState{
name: toolName,
index: blockIndex,
loadingID: newLoadingID,
}
lastToolLoadingID = newLoadingID
log.Printf("[Sandbox] Tool started: %s", toolName)
}
}
}
case "content_block_delta": case "content_block_delta":
// Streaming text content if delta, ok := event["delta"].(map[string]interface{}); ok {
if delta, ok := msg.Content.(map[string]interface{}); ok { deltaType, _ := delta["type"].(string)
if text, ok := delta["text"].(string); ok { switch deltaType {
case "text_delta":
if text, ok := delta["text"].(string); ok && text != "" {
// Close "preparing sandbox" loading on first text output
closePrepLoading()
// Send to stream handler for real-time output
if handler != nil {
// Send ChunkMessageStart first if not already started
if !messageStarted {
startData := message.EventMessageStartData{
MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
Type: "text",
Timestamp: time.Now().UnixMilli(),
}
startDataJSON, _ := json.Marshal(startData)
handler(message.ChunkMessageStart, startDataJSON)
messageStarted = true
}
handler(message.ChunkText, []byte(text))
}
// Also accumulate for final response
textContent.WriteString(text)
}
case "input_json_delta":
// Accumulate tool input JSON fragments
if currentTool != nil {
if partialJSON, ok := delta["partial_json"].(string); ok {
currentTool.inputJSON.WriteString(partialJSON)
}
}
}
}
case "content_block_stop":
// Tool input complete - parse and update loading with detailed info
if currentTool != nil && currentTool.loadingID != "" && ctx != nil {
inputStr := currentTool.inputJSON.String()
if inputStr != "" {
// Use gou/json.Parse for fault-tolerant parsing
locale := ctx.Locale
detailedMsg := getToolDetailedDescription(currentTool.name, inputStr, locale)
if detailedMsg != "" {
toolMsg := &message.Message{
MessageID: currentTool.loadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": detailedMsg,
},
}
ctx.Send(toolMsg)
log.Printf("[Sandbox] Tool: %s -> %s", currentTool.name, detailedMsg)
}
}
// Note: Don't close loading here - it will be closed when next tool starts or at end
// Reset tool state but keep lastToolLoadingID to close it later
currentTool = nil
}
}
}
case "assistant":
// Assistant message - extract content
// With --include-partial-messages, we receive real-time text via stream_event
// The assistant message contains the full accumulated content
if msgData, ok := msg["message"].(map[string]interface{}); ok {
// Get model from message
if m, ok := msgData["model"].(string); ok && model == "" {
model = m
}
// Check if this is the final message (has stop_reason)
stopReason, hasStopReason := msgData["stop_reason"].(string)
isFinalMessage := hasStopReason && stopReason != ""
// Extract content from final message
// This serves as a fallback if stream_event wasn't received
if isFinalMessage {
if contentArr, ok := msgData["content"].([]interface{}); ok {
for _, item := range contentArr {
if contentItem, ok := item.(map[string]interface{}); ok {
itemType, _ := contentItem["type"].(string)
switch itemType {
case "text":
// Only use this if we haven't already accumulated text from stream_event
if textContent.Len() == 0 {
if text, ok := contentItem["text"].(string); ok && text != "" {
textContent.WriteString(text) textContent.WriteString(text)
// Send to stream handler if available // Send to stream handler if available
if handler != nil { if handler != nil {
if !messageStarted {
startData := message.EventMessageStartData{
MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
Type: "text",
Timestamp: time.Now().UnixMilli(),
}
startDataJSON, _ := json.Marshal(startData)
handler(message.ChunkMessageStart, startDataJSON)
messageStarted = true
}
handler(message.ChunkText, []byte(text)) handler(message.ChunkText, []byte(text))
} }
} }
} }
case "message_delta": case "tool_use":
// Message completion with usage toolName := getString(contentItem, "name")
if content, ok := msg.Content.(map[string]interface{}); ok { toolCall := agentContext.ToolCall{
if usageData, ok := content["usage"].(map[string]interface{}); ok { ID: getString(contentItem, "id"),
Type: agentContext.ToolTypeFunction,
Function: agentContext.Function{
Name: toolName,
},
}
// Get input as JSON string
var inputJSONStr string
if input, ok := contentItem["input"]; ok {
if inputJSON, err := json.Marshal(input); err == nil {
inputJSONStr = string(inputJSON)
toolCall.Function.Arguments = inputJSONStr
}
}
toolCalls = append(toolCalls, toolCall)
// Create tool loading message (from complete assistant message)
// This is a fallback for when stream_event wasn't received
if toolName != "" && ctx != nil {
// Close previous tool loading if exists
if lastToolLoadingID != "" {
doneMsg := &message.Message{
MessageID: lastToolLoadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": "",
"done": true,
},
}
ctx.Send(doneMsg)
}
// Create new loading for this tool
locale := ctx.Locale
detailedMsg := getToolDetailedDescription(toolName, inputJSONStr, locale)
if detailedMsg != "" {
toolLoadingMsg := &message.Message{
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": detailedMsg,
},
}
newLoadingID, _ := ctx.SendStream(toolLoadingMsg)
lastToolLoadingID = newLoadingID
log.Printf("[Sandbox] Tool: %s -> %s", toolName, detailedMsg)
}
}
}
}
}
}
}
// Extract usage (from any message that has it)
if usageData, ok := msgData["usage"].(map[string]interface{}); ok {
usage = &message.UsageInfo{} usage = &message.UsageInfo{}
if v, ok := usageData["input_tokens"].(float64); ok { if v, ok := usageData["input_tokens"].(float64); ok {
usage.PromptTokens = int(v) usage.PromptTokens = int(v)
@ -288,32 +772,32 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
} }
} }
case "message_start": case "result":
// Extract model from message_start // Final result message
if content, ok := msg.Content.(map[string]interface{}); ok { // Check if this is an error result (is_error: true)
if m, ok := content["model"].(string); ok { isError, _ := msg["is_error"].(bool)
model = m if result, ok := msg["result"].(string); ok {
if isError {
// This is an error - return it as an error
return nil, fmt.Errorf("Claude CLI error: %s", result)
} }
finalResult = result
} }
// Send done signal to handler (only if message was started and not an error)
case "content_block_start": if handler != nil && messageStarted && !isError {
// Might contain tool use blocks handler(message.ChunkMessageEnd, nil)
if block, ok := msg.Content.(map[string]interface{}); ok {
if block["type"] == "tool_use" {
toolCall := agentContext.ToolCall{
ID: getString(block, "id"),
Type: agentContext.ToolTypeFunction,
Function: agentContext.Function{
Name: getString(block, "name"),
Arguments: "{}",
},
}
toolCalls = append(toolCalls, toolCall)
}
} }
case "error": case "error":
return nil, fmt.Errorf("Claude CLI error: %s", msg.Error) // Error message
if errMsg, ok := msg["error"].(string); ok {
return nil, fmt.Errorf("Claude CLI error: %s", errMsg)
}
if errObj, ok := msg["error"].(map[string]interface{}); ok {
if errMsg, ok := errObj["message"].(string); ok {
return nil, fmt.Errorf("Claude CLI error: %s", errMsg)
}
}
} }
} }
@ -321,13 +805,34 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
return nil, fmt.Errorf("error reading stream: %w", err) return nil, fmt.Errorf("error reading stream: %w", err)
} }
// Close the last tool loading message if exists
if lastToolLoadingID != "" && ctx != nil {
doneMsg := &message.Message{
MessageID: lastToolLoadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": "",
"done": true,
},
}
ctx.Send(doneMsg)
}
// Use final result if available, otherwise use accumulated text content
content := textContent.String()
if finalResult != "" && content == "" {
content = finalResult
}
// Build response // Build response
response := &agentContext.CompletionResponse{ response := &agentContext.CompletionResponse{
ID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), ID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
Model: model, Model: model,
Created: time.Now().Unix(), Created: time.Now().Unix(),
Role: "assistant", Role: "assistant",
Content: textContent.String(), Content: content,
FinishReason: agentContext.FinishReasonStop, FinishReason: agentContext.FinishReasonStop,
} }
@ -345,6 +850,161 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
return response, nil return response, nil
} }
// truncateStr truncates a string to maxLen characters
func truncateStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// getToolDescription returns a human-readable, localized description for a Claude CLI tool
func getToolDescription(toolName string, locale string) string {
// Map tool names to i18n keys
toolKeys := map[string]string{
"Read": "sandbox.tool.read",
"Write": "sandbox.tool.write",
"Edit": "sandbox.tool.edit",
"StrReplace": "sandbox.tool.edit",
"Bash": "sandbox.tool.bash",
"Shell": "sandbox.tool.bash",
"Glob": "sandbox.tool.glob",
"Grep": "sandbox.tool.grep",
"LS": "sandbox.tool.ls",
"Task": "sandbox.tool.task",
"WebSearch": "sandbox.tool.web_search",
"WebFetch": "sandbox.tool.web_fetch",
"TodoWrite": "sandbox.tool.todo_write",
"AskQuestion": "sandbox.tool.ask_question",
"SwitchMode": "sandbox.tool.switch_mode",
"ReadLints": "sandbox.tool.read_lints",
"EditNotebook": "sandbox.tool.edit_notebook",
}
if key, ok := toolKeys[toolName]; ok {
return i18n.T(locale, key)
}
// For unknown tools, use the unknown key and replace {{name}} manually
template := i18n.T(locale, "sandbox.tool.unknown")
return strings.Replace(template, "{{name}}", toolName, 1)
}
// getToolDetailedDescription returns a detailed description with specific parameters
// It parses the tool input JSON and extracts key information to show users
func getToolDetailedDescription(toolName string, inputJSON string, locale string) string {
// Parse the input JSON using fault-tolerant parser
parsed, err := goujson.Parse(inputJSON)
if err != nil {
// Fall back to basic description if parsing fails
return getToolDescription(toolName, locale)
}
input, ok := parsed.(map[string]interface{})
if !ok {
return getToolDescription(toolName, locale)
}
// Extract key information based on tool type
var detail string
switch toolName {
case "Bash", "Shell":
// Show the command being executed
if cmd, ok := input["command"].(string); ok && cmd != "" {
// Truncate long commands
if len(cmd) > 50 {
cmd = cmd[:47] + "..."
}
detail = cmd
}
case "Read":
// Show the file being read
if path, ok := input["path"].(string); ok && path != "" {
detail = filepath.Base(path)
}
case "Write":
// Show the file being written
// Note: Claude CLI uses "file_path" for Write tool, not "path"
if path, ok := input["file_path"].(string); ok && path != "" {
detail = filepath.Base(path)
} else if path, ok := input["path"].(string); ok && path != "" {
detail = filepath.Base(path)
}
case "Edit", "StrReplace":
// Show the file being edited
if path, ok := input["path"].(string); ok && path != "" {
detail = filepath.Base(path)
}
case "Glob":
// Show the glob pattern
if pattern, ok := input["glob_pattern"].(string); ok && pattern != "" {
detail = pattern
} else if pattern, ok := input["pattern"].(string); ok && pattern != "" {
detail = pattern
}
case "Grep":
// Show the search pattern
if pattern, ok := input["pattern"].(string); ok && pattern != "" {
if len(pattern) > 30 {
pattern = pattern[:27] + "..."
}
detail = pattern
}
case "LS":
// Show the directory
if path, ok := input["target_directory"].(string); ok && path != "" {
detail = filepath.Base(path)
} else if path, ok := input["path"].(string); ok && path != "" {
detail = filepath.Base(path)
}
case "WebSearch":
// Show the search query
if query, ok := input["search_term"].(string); ok && query != "" {
if len(query) > 40 {
query = query[:37] + "..."
}
detail = query
} else if query, ok := input["query"].(string); ok && query != "" {
if len(query) > 40 {
query = query[:37] + "..."
}
detail = query
}
case "WebFetch":
// Show the URL
if url, ok := input["url"].(string); ok && url != "" {
// Extract domain from URL
if len(url) > 50 {
url = url[:47] + "..."
}
detail = url
}
case "Task":
// Show the task description
if desc, ok := input["description"].(string); ok && desc != "" {
if len(desc) > 40 {
desc = desc[:37] + "..."
}
detail = desc
}
}
// Build the message with detail
baseMsg := getToolDescription(toolName, locale)
if detail != "" {
return baseMsg + ": " + detail
}
return baseMsg
}
// ReadFile reads a file from the container // ReadFile reads a file from the container
func (e *Executor) ReadFile(ctx context.Context, path string) ([]byte, error) { func (e *Executor) ReadFile(ctx context.Context, path string) ([]byte, error) {
// Make path absolute if not // Make path absolute if not

View file

@ -190,6 +190,7 @@ func TestClaudeExecutorMCPConfigWrite(t *testing.T) {
UserID: "test-user", UserID: "test-user",
ChatID: fmt.Sprintf("test-chat-mcp-write-%d", time.Now().UnixNano()), ChatID: fmt.Sprintf("test-chat-mcp-write-%d", time.Now().UnixNano()),
MCPConfig: mcpConfig, MCPConfig: mcpConfig,
// No connector config - skip proxy start for alpine test image
} }
exec, err := NewExecutor(manager, opts) exec, err := NewExecutor(manager, opts)
@ -245,6 +246,7 @@ func TestClaudeExecutorSkillsCopy(t *testing.T) {
UserID: "test-user", UserID: "test-user",
ChatID: fmt.Sprintf("test-chat-skills-%d", time.Now().UnixNano()), ChatID: fmt.Sprintf("test-chat-skills-%d", time.Now().UnixNano()),
SkillsDir: skillsDir, SkillsDir: skillsDir,
// No connector config - skip proxy start for alpine test image
} }
exec, err := NewExecutor(manager, opts) exec, err := NewExecutor(manager, opts)
@ -316,11 +318,9 @@ func TestClaudeExecutorPrepareEnvironmentIntegration(t *testing.T) {
Image: "alpine:latest", Image: "alpine:latest",
UserID: "test-user", UserID: "test-user",
ChatID: fmt.Sprintf("test-chat-full-env-%d", time.Now().UnixNano()), ChatID: fmt.Sprintf("test-chat-full-env-%d", time.Now().UnixNano()),
ConnectorHost: "https://api.test.com",
ConnectorKey: "test-key",
Model: "test-model",
MCPConfig: mcpConfig, MCPConfig: mcpConfig,
SkillsDir: skillsDir, SkillsDir: skillsDir,
// No connector config - skip proxy start for alpine test image
} }
exec, err := NewExecutor(manager, opts) exec, err := NewExecutor(manager, opts)
@ -333,26 +333,19 @@ func TestClaudeExecutorPrepareEnvironmentIntegration(t *testing.T) {
err = exec.prepareEnvironment(ctx) err = exec.prepareEnvironment(ctx)
require.NoError(t, err, "prepareEnvironment should succeed") require.NoError(t, err, "prepareEnvironment should succeed")
// Verify all files exist // 1. Check MCP config
// 1. Check CCR config
ccrContent, err := exec.Exec(ctx, []string{"cat", "/home/sandbox/.claude-code-router/config.json"})
require.NoError(t, err, "CCR config should exist")
assert.Contains(t, ccrContent, "api_base_url", "CCR config should contain api_base_url")
t.Logf("✓ CCR config verified: %d bytes", len(ccrContent))
// 2. Check MCP config
mcpContent, err := exec.ReadFile(ctx, ".mcp.json") mcpContent, err := exec.ReadFile(ctx, ".mcp.json")
require.NoError(t, err, "MCP config should exist in container") require.NoError(t, err, "MCP config should exist in container")
assert.JSONEq(t, string(mcpConfig), string(mcpContent), "MCP config content should match") assert.JSONEq(t, string(mcpConfig), string(mcpContent), "MCP config content should match")
t.Logf("✓ MCP config verified: %s", string(mcpContent)) t.Logf("✓ MCP config verified: %s", string(mcpContent))
// 3. Check Skills directory structure // 2. Check Skills directory structure
output, err := exec.Exec(ctx, []string{"ls", "-la", ".claude/skills"}) output, err := exec.Exec(ctx, []string{"ls", "-la", ".claude/skills"})
require.NoError(t, err, "Skills directory should exist in container") require.NoError(t, err, "Skills directory should exist in container")
assert.Contains(t, output, "echo-test", "echo-test skill should exist") assert.Contains(t, output, "echo-test", "echo-test skill should exist")
t.Logf("✓ Skills directory contents:\n%s", output) t.Logf("✓ Skills directory contents:\n%s", output)
// 4. Check skill content // 3. Check skill content
skillContent, err := exec.ReadFile(ctx, ".claude/skills/echo-test/SKILL.md") skillContent, err := exec.ReadFile(ctx, ".claude/skills/echo-test/SKILL.md")
require.NoError(t, err, "SKILL.md should exist in container") require.NoError(t, err, "SKILL.md should exist in container")
require.NotEmpty(t, skillContent, "SKILL.md should not be empty") require.NotEmpty(t, skillContent, "SKILL.md should not be empty")

View file

@ -0,0 +1,310 @@
package claude
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/config"
infraSandbox "github.com/yaoapp/yao/sandbox"
"github.com/yaoapp/yao/test"
)
// TestRealClaudeCLIExecution tests real Claude CLI execution with streaming
// This test requires:
// 1. Docker running with yaoapp/sandbox-claude:latest image
// 2. Environment variables: DEEPSEEK_API_KEY, DEEPSEEK_API_PROXY, DEEPSEEK_MODELS_V3
func TestRealClaudeCLIExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real E2E test in short mode")
}
// Check for required environment variables
apiKey := os.Getenv("DEEPSEEK_API_KEY")
apiProxy := os.Getenv("DEEPSEEK_API_PROXY")
model := os.Getenv("DEEPSEEK_MODELS_V3")
if apiKey == "" || apiProxy == "" || model == "" {
t.Skip("Skipping test: DEEPSEEK_API_KEY, DEEPSEEK_API_PROXY, or DEEPSEEK_MODELS_V3 not set")
}
test.Prepare(t, config.Conf)
defer test.Clean()
// Get data root from environment
dataRoot := os.Getenv("YAO_ROOT")
if dataRoot == "" {
t.Skip("Skipping test: YAO_ROOT not set")
}
// Create config with proper paths
cfg := infraSandbox.DefaultConfig()
cfg.Init(dataRoot)
manager, err := infraSandbox.NewManager(cfg)
if err != nil {
t.Skipf("Skipping test: Docker not available: %v", err)
}
defer manager.Close()
// Create options WITH SystemPrompt (triggers Claude CLI execution)
opts := &Options{
Command: "claude",
Image: "yaoapp/sandbox-claude:latest",
UserID: "test-user",
ChatID: fmt.Sprintf("test-real-e2e-%d", time.Now().UnixNano()),
ConnectorHost: apiProxy,
ConnectorKey: apiKey,
Model: model,
SystemPrompt: "You are a helpful assistant. Reply concisely.",
Timeout: 3 * time.Minute,
}
t.Logf("Creating executor with options:")
t.Logf(" ConnectorHost: %s", opts.ConnectorHost)
t.Logf(" Model: %s", opts.Model)
t.Logf(" SystemPrompt: %s", opts.SystemPrompt)
exec, err := NewExecutor(manager, opts)
if err != nil {
t.Skipf("Skipping test: Failed to create executor: %v", err)
}
defer exec.Close()
// Verify shouldSkipClaudeCLI returns false
if exec.shouldSkipClaudeCLI() {
t.Fatal("shouldSkipClaudeCLI should return false when SystemPrompt is set")
}
// Test 1: First, manually test claude-proxy
t.Log("=== Test 1: Verify claude-proxy is working ===")
stdCtx := context.Background()
// Prepare environment (this starts claude-proxy)
err = exec.prepareEnvironment(stdCtx)
require.NoError(t, err, "prepareEnvironment should succeed")
// Check proxy is running
result, err := exec.manager.Exec(stdCtx, exec.containerName, []string{"pgrep", "-f", "claude-proxy"}, nil)
if err != nil || result.ExitCode != 0 {
t.Log("claude-proxy not running, checking why...")
// Check proxy log
logContent, _ := exec.ReadFile(stdCtx, "proxy.log")
t.Logf("Proxy log: %s", string(logContent))
// Check config
configContent, _ := exec.ReadFile(stdCtx, ".claude-proxy.json")
t.Logf("Proxy config: %s", string(configContent))
} else {
t.Logf("claude-proxy is running with PID: %s", strings.TrimSpace(result.Stdout))
}
// Test 2: Test simple command execution
t.Log("=== Test 2: Simple command execution ===")
ctx := agentContext.New(stdCtx, nil, opts.ChatID)
messages := []agentContext.Message{
{Role: "user", Content: "Reply with exactly: HELLO_TEST_SUCCESS"},
}
// Collect streaming output
var streamedChunks []string
var streamedContent strings.Builder
streamHandler := func(chunkType message.StreamChunkType, data []byte) int {
chunk := string(data)
streamedChunks = append(streamedChunks, chunk)
streamedContent.Write(data)
t.Logf("Stream chunk [%s]: %q", chunkType, chunk)
return 0 // continue streaming
}
t.Log("Executing Claude CLI...")
startTime := time.Now()
response, err := exec.Stream(ctx, messages, streamHandler)
duration := time.Since(startTime)
t.Logf("Execution took: %v", duration)
if err != nil {
t.Logf("Stream error: %v", err)
// Debug: check what's in the container
t.Log("=== Debug info ===")
// Check proxy log
logContent, _ := exec.ReadFile(stdCtx, "proxy.log")
t.Logf("Proxy log:\n%s", string(logContent))
// List workspace
output, _ := exec.Exec(stdCtx, []string{"ls", "-la", "/workspace"})
t.Logf("Workspace contents:\n%s", output)
// Check environment
output, _ = exec.Exec(stdCtx, []string{"env"})
t.Logf("Environment:\n%s", output)
t.Fatalf("Stream failed: %v", err)
}
require.NotNil(t, response, "Response should not be nil")
// Log results
t.Logf("=== Results ===")
t.Logf("Response ID: %s", response.ID)
t.Logf("Response Model: %s", response.Model)
t.Logf("Response Content: %v", response.Content)
t.Logf("Streamed chunks count: %d", len(streamedChunks))
t.Logf("Total streamed content: %s", streamedContent.String())
// Verify we got some response
var fullResponse string
if content, ok := response.Content.(string); ok {
fullResponse = content
}
if fullResponse == "" {
fullResponse = streamedContent.String()
}
if fullResponse == "" {
// Check proxy log for errors
logContent, _ := exec.ReadFile(stdCtx, "proxy.log")
t.Logf("Proxy log (for debugging):\n%s", string(logContent))
t.Fatal("Got empty response from Claude CLI")
}
t.Logf("✓ Successfully got response: %s", fullResponse)
// Check if streaming worked
if len(streamedChunks) > 0 {
t.Logf("✓ Streaming worked with %d chunks", len(streamedChunks))
} else {
t.Log("⚠ No streaming chunks received (might be buffered)")
}
}
// TestClaudeCLIDirectExecution tests running claude directly in the container
func TestClaudeCLIDirectExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real E2E test in short mode")
}
// Check for required environment variables
apiKey := os.Getenv("DEEPSEEK_API_KEY")
apiProxy := os.Getenv("DEEPSEEK_API_PROXY")
model := os.Getenv("DEEPSEEK_MODELS_V3")
if apiKey == "" || apiProxy == "" || model == "" {
t.Skip("Skipping test: DEEPSEEK_API_KEY, DEEPSEEK_API_PROXY, or DEEPSEEK_MODELS_V3 not set")
}
test.Prepare(t, config.Conf)
defer test.Clean()
dataRoot := os.Getenv("YAO_ROOT")
if dataRoot == "" {
t.Skip("Skipping test: YAO_ROOT not set")
}
cfg := infraSandbox.DefaultConfig()
cfg.Init(dataRoot)
manager, err := infraSandbox.NewManager(cfg)
if err != nil {
t.Skipf("Skipping test: Docker not available: %v", err)
}
defer manager.Close()
opts := &Options{
Command: "claude",
Image: "yaoapp/sandbox-claude:latest",
UserID: "test-user",
ChatID: fmt.Sprintf("test-direct-%d", time.Now().UnixNano()),
ConnectorHost: apiProxy,
ConnectorKey: apiKey,
Model: model,
Timeout: 3 * time.Minute,
}
exec, err := NewExecutor(manager, opts)
if err != nil {
t.Skipf("Skipping test: Failed to create executor: %v", err)
}
defer exec.Close()
stdCtx := context.Background()
// Step 1: Write proxy config and start proxy
t.Log("=== Step 1: Start claude-proxy ===")
err = exec.prepareEnvironment(stdCtx)
require.NoError(t, err)
// Wait for proxy to start
time.Sleep(2 * time.Second)
// Check proxy status
result, err := exec.manager.Exec(stdCtx, exec.containerName, []string{"pgrep", "-f", "claude-proxy"}, nil)
if err == nil && result.ExitCode == 0 {
t.Logf("✓ claude-proxy running, PID: %s", strings.TrimSpace(result.Stdout))
} else {
t.Log("⚠ claude-proxy might not be running")
}
// Step 2: Run claude CLI directly with simple prompt
t.Log("=== Step 2: Run claude CLI directly ===")
// Build a simple command - pass env vars explicitly
directCmd := []string{
"bash", "-c",
`echo '{"type":"user","message":{"role":"user","content":"say hello"}}' | claude -p --dangerously-skip-permissions --permission-mode bypassPermissions --input-format stream-json --output-format stream-json --verbose 2>&1`,
}
reader, err := exec.manager.Stream(stdCtx, exec.containerName, directCmd, &infraSandbox.ExecOptions{
WorkDir: exec.workDir,
Timeout: 2 * time.Minute,
Env: map[string]string{
"ANTHROPIC_BASE_URL": "http://127.0.0.1:3456",
"ANTHROPIC_API_KEY": "dummy",
},
})
if err != nil {
t.Fatalf("Failed to execute: %v", err)
}
defer reader.Close()
// Read output
buf := make([]byte, 64*1024)
var output strings.Builder
for {
n, err := reader.Read(buf)
if n > 0 {
chunk := string(buf[:n])
output.WriteString(chunk)
t.Logf("Output chunk: %q", chunk)
}
if err != nil {
break
}
}
t.Logf("=== Full output ===\n%s", output.String())
if output.Len() == 0 {
// Check logs
logContent, _ := exec.ReadFile(stdCtx, "proxy.log")
t.Logf("Proxy log:\n%s", string(logContent))
t.Fatal("Got no output from claude CLI")
}
// Check for success indicators
outputStr := output.String()
if strings.Contains(outputStr, "error") || strings.Contains(outputStr, "Error") {
t.Logf("⚠ Output contains error")
}
if strings.Contains(outputStr, "content_block") || strings.Contains(outputStr, "message_start") {
t.Log("✓ Got streaming JSON output from Claude CLI")
}
}

View file

@ -35,11 +35,14 @@ func New(manager *infraSandbox.Manager, opts *Options) (Executor, error) {
UserID: opts.UserID, UserID: opts.UserID,
ChatID: opts.ChatID, ChatID: opts.ChatID,
MCPConfig: opts.MCPConfig, MCPConfig: opts.MCPConfig,
MCPTools: opts.MCPTools, // MCP tools to expose via IPC MCPTools: opts.MCPTools,
SkillsDir: opts.SkillsDir, SkillsDir: opts.SkillsDir,
SystemPrompt: opts.SystemPrompt, // Required for Claude CLI execution
ConnectorHost: opts.ConnectorHost, ConnectorHost: opts.ConnectorHost,
ConnectorKey: opts.ConnectorKey, ConnectorKey: opts.ConnectorKey,
Model: opts.Model, Model: opts.Model,
ConnectorOptions: opts.ConnectorOptions, // Extra options like thinking, max_tokens
Secrets: opts.Secrets, // Secrets for container env vars
} }
return claude.NewExecutor(manager, claudeOpts) return claude.NewExecutor(manager, claudeOpts)
case "cursor": case "cursor":

View file

@ -18,6 +18,9 @@ type Executor interface {
// Stream runs the request with streaming output (uses options set at creation time) // Stream runs the request with streaming output (uses options set at creation time)
Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error)
// SetLoadingMsgID sets the loading message ID for tool execution status updates
SetLoadingMsgID(id string)
// Filesystem operations (for Hooks) // Filesystem operations (for Hooks)
ReadFile(ctx context.Context, path string) ([]byte, error) ReadFile(ctx context.Context, path string) ([]byte, error)
WriteFile(ctx context.Context, path string, content []byte) error WriteFile(ctx context.Context, path string, content []byte) error
@ -74,11 +77,23 @@ type Options struct {
// Skills directory - auto-resolved to assistants/{name}/skills/ // Skills directory - auto-resolved to assistants/{name}/skills/
SkillsDir string `json:"-"` SkillsDir string `json:"-"`
// SystemPrompt - extracted from assistant prompts.yml
// Used to determine if Claude CLI should be called
SystemPrompt string `json:"-"`
// Connector settings - auto-resolved from connector config file // Connector settings - auto-resolved from connector config file
// e.g., connectors/deepseek/v3.conn.yao → host, key, model // e.g., connectors/deepseek/v3.conn.yao → host, key, model
ConnectorHost string `json:"-"` ConnectorHost string `json:"-"`
ConnectorKey string `json:"-"` ConnectorKey string `json:"-"`
Model string `json:"-"` Model string `json:"-"`
// ConnectorOptions - extra options from connector config (e.g., thinking, max_tokens, temperature)
// These are backend-specific parameters passed to the proxy
ConnectorOptions map[string]interface{} `json:"-"`
// Secrets - sensitive values from sandbox.secrets config (e.g., GITHUB_TOKEN)
// Resolved from $ENV.XXX references, exported as env vars in container
Secrets map[string]string `json:"-"`
} }
// SandboxConfig represents the sandbox configuration in assistant package.yao // SandboxConfig represents the sandbox configuration in assistant package.yao

View file

@ -370,6 +370,7 @@ type Sandbox struct {
MaxCPU float64 `json:"max_cpu,omitempty"` // CPU limit (e.g., 2.0) MaxCPU float64 `json:"max_cpu,omitempty"` // CPU limit (e.g., 2.0)
Timeout string `json:"timeout,omitempty"` // Execution timeout (e.g., "10m") Timeout string `json:"timeout,omitempty"` // Execution timeout (e.g., "10m")
Arguments map[string]interface{} `json:"arguments,omitempty"` // Command-specific arguments Arguments map[string]interface{} `json:"arguments,omitempty"` // Command-specific arguments
Secrets map[string]string `json:"secrets,omitempty"` // Secrets to pass to container (e.g., GITHUB_TOKEN: "$ENV.GITHUB_TOKEN")
} }
// Tool represents a tool configuration for storage // Tool represents a tool configuration for storage

View file

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

View file

@ -1,4 +1,4 @@
# Claude sandbox image: Claude CLI + Node.js + Python + CCR # Claude sandbox image: Claude CLI + Node.js + Python + claude-proxy
# Supports both amd64 and arm64 architectures # Supports both amd64 and arm64 architectures
# Base: Ubuntu 24.04 LTS # Base: Ubuntu 24.04 LTS
ARG REGISTRY=yaoapp ARG REGISTRY=yaoapp
@ -25,6 +25,14 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# GitHub CLI (gh) for repository operations
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \
&& apt-get install -y gh \
&& rm -rf /var/lib/apt/lists/*
# npm global packages directory for sandbox user # npm global packages directory for sandbox user
RUN mkdir -p /home/sandbox/.npm-global && \ RUN mkdir -p /home/sandbox/.npm-global && \
chown -R sandbox:sandbox /home/sandbox/.npm-global chown -R sandbox:sandbox /home/sandbox/.npm-global
@ -39,54 +47,262 @@ 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 # Create Claude CLI configuration for auto-approve all operations
# Supports: DeepSeek, GLM, Volcengine, OpenRouter, etc. # This is CRITICAL for non-interactive sandbox usage
RUN npm install -g @musistudio/claude-code-router # The --dangerously-skip-permissions flag alone is not enough;
# we also need the settings.json to fully bypass permission prompts
RUN mkdir -p /home/sandbox/.claude && \
cat > /home/sandbox/.claude/settings.json << 'EOF'
{
"permissions": {
"defaultMode": "bypassPermissions",
"allow": ["*"],
"deny": []
}
}
EOF
# 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)
# Config is read from /tmp/.yao/proxy.json (NOT /workspace/ for security - api_key/secrets hidden from user)
RUN cat > /usr/local/bin/start-claude-proxy << 'SCRIPT'
#!/bin/bash
# Start claude-proxy from config file or environment variables
# Config file: /tmp/.yao/proxy.json (secure location, not visible to user file manager)
# Format: {"backend": "...", "api_key": "...", "model": "...", "options": {...}, "secrets": {...}}
CONFIG_FILE="/tmp/.yao/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)
# Read extra options as JSON string (e.g., {"thinking":{"type":"enabled"}})
OPTIONS=$(jq -c '.options // 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
# Only set options if it's a valid non-empty JSON object
if [ -n "$OPTIONS" ] && [ "$OPTIONS" != "null" ] && [ "$OPTIONS" != "" ]; then
export CLAUDE_PROXY_OPTIONS="$OPTIONS"
fi
# Export secrets as environment variables for Claude CLI to use
# e.g., {"GITHUB_TOKEN": "ghp_xxx"} -> export GITHUB_TOKEN=ghp_xxx
SECRETS=$(jq -c '.secrets // empty' "$CONFIG_FILE" 2>/dev/null)
if [ -n "$SECRETS" ] && [ "$SECRETS" != "null" ] && [ "$SECRETS" != "" ] && [ "$SECRETS" != "{}" ]; then
# Parse each key-value pair and export
for key in $(echo "$SECRETS" | jq -r 'keys[]' 2>/dev/null); do
value=$(echo "$SECRETS" | jq -r --arg k "$key" '.[$k]' 2>/dev/null)
if [ -n "$value" ] && [ "$value" != "null" ]; then
export "$key"="$value"
fi
done
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 with environment variables explicitly passed
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
nohup env \
CLAUDE_PROXY_BACKEND="$CLAUDE_PROXY_BACKEND" \
CLAUDE_PROXY_API_KEY="$CLAUDE_PROXY_API_KEY" \
CLAUDE_PROXY_MODEL="$CLAUDE_PROXY_MODEL" \
CLAUDE_PROXY_OPTIONS="$CLAUDE_PROXY_OPTIONS" \
/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"]

View file

@ -42,6 +42,8 @@ type demuxReadCloser struct {
closer io.Closer closer io.Closer
done chan struct{} done chan struct{}
err error err error
closed bool
mu sync.Mutex
} }
// newDemuxReadCloser creates a new demuxed reader from Docker multiplexed stream // newDemuxReadCloser creates a new demuxed reader from Docker multiplexed stream
@ -60,11 +62,20 @@ func newDemuxReadCloser(src io.Reader, closer io.Closer) *demuxReadCloser {
defer close(d.done) defer close(d.done)
defer pw.Close() defer pw.Close()
fmt.Printf("[DEBUG demux] Starting stdcopy.StdCopy\n")
startTime := time.Now()
// Use stdcopy to demux stdout and stderr // Use stdcopy to demux stdout and stderr
// We only care about stdout here, stderr goes to a discard writer // We only care about stdout here, stderr goes to a discard writer
_, err := stdcopy.StdCopy(pw, io.Discard, src) n, err := stdcopy.StdCopy(pw, io.Discard, src)
elapsed := time.Since(startTime)
fmt.Printf("[DEBUG demux] stdcopy.StdCopy returned: bytes=%d, err=%v, elapsed=%v\n", n, err, elapsed)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
d.mu.Lock()
d.err = err d.err = err
d.mu.Unlock()
} }
}() }()
@ -76,15 +87,39 @@ func (d *demuxReadCloser) Read(p []byte) (int, error) {
} }
func (d *demuxReadCloser) Close() error { func (d *demuxReadCloser) Close() error {
// Close the source to stop the demux goroutine d.mu.Lock()
if d.closed {
d.mu.Unlock()
return nil
}
d.closed = true
d.mu.Unlock()
// Close the pipe writer first to signal EOF to any readers
// This will cause pipeReader.Read() to return io.EOF
d.pipeWriter.CloseWithError(io.EOF)
// Close the source connection to interrupt stdcopy.StdCopy
if d.closer != nil { if d.closer != nil {
d.closer.Close() d.closer.Close()
} }
// Close the pipe reader to unblock any pending reads // Close the pipe reader to unblock any pending reads
d.pipeReader.Close() d.pipeReader.Close()
// Wait for demux goroutine to finish
<-d.done // Wait for demux goroutine to finish with a timeout
return d.err // Don't block forever if stdcopy.StdCopy is stuck
select {
case <-d.done:
// Normal completion
case <-time.After(5 * time.Second):
fmt.Printf("[DEBUG demux] Timeout waiting for demux goroutine to finish\n")
}
d.mu.Lock()
err := d.err
d.mu.Unlock()
return err
} }
// Manager manages sandbox containers // Manager manages sandbox containers
@ -562,6 +597,39 @@ func (m *Manager) Remove(ctx context.Context, name string) error {
return nil return nil
} }
// KillProcess kills a process inside the container by name pattern
// This is used to forcefully stop long-running processes like Claude CLI
func (m *Manager) KillProcess(ctx context.Context, name string, processPattern string) error {
c, ok := m.containers.Load(name)
if !ok {
return ErrContainerNotFound
}
cont := c.(*Container)
// Use pkill to kill processes matching the pattern
// -f matches against the full command line
// Use SIGKILL (-9) to ensure the process is killed immediately
cmd := []string{"pkill", "-9", "-f", processPattern}
execConfig := container.ExecOptions{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
}
execResp, err := m.dockerClient.ContainerExecCreate(ctx, cont.ID, execConfig)
if err != nil {
return fmt.Errorf("failed to create exec for kill: %w", err)
}
// Start the exec
if err := m.dockerClient.ContainerExecStart(ctx, execResp.ID, container.ExecStartOptions{}); err != nil {
return fmt.Errorf("failed to start exec for kill: %w", err)
}
return nil
}
// List returns all containers for a user // List returns all containers for a user
func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error) { func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error) {
var result []*Container var result []*Container

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

@ -0,0 +1,214 @@
# Claude API Proxy
A lightweight API proxy that allows Claude CLI to use any OpenAI-compatible backend API (Volcengine, DeepSeek, GLM, etc.).
## Features
- **Zero dependencies**: Uses only Go standard library
- **Lightweight**: Single executable binary
- **True streaming**: Direct SSE forwarding, no buffering
- **Full tool calling support**: Both streaming and non-streaming
- **Image content support**: Base64 and URL formats
- **Multi-architecture**: Supports amd64 and arm64
## Architecture
```
Claude CLI (Anthropic Messages API)
claude-proxy (localhost:3456)
│ Convert: Anthropic → OpenAI
OpenAI-compatible Backend (Volcengine/DeepSeek/GLM...)
│ Convert: OpenAI → Anthropic
Claude CLI (Real-time streaming output)
```
## Command Line Options
```bash
claude-proxy [options]
Options:
-p, --port <port> Listen port (default: 3456)
-b, --backend <url> Backend API URL (required)
-m, --model <model> Backend model name (required)
-k, --api-key <key> Backend API key (required)
-l, --log <path> Log file path
-t, --timeout <seconds> Request timeout (default: 300)
-v, --verbose Verbose logging
-h, --help Show help
Environment Variables:
CLAUDE_PROXY_PORT Listen port
CLAUDE_PROXY_BACKEND Backend API URL
CLAUDE_PROXY_MODEL Backend model name
CLAUDE_PROXY_API_KEY Backend API key
CLAUDE_PROXY_TIMEOUT Timeout in seconds
```
## Usage
### Method 1: Command Line Arguments
```bash
# Using Volcengine GLM-4
claude-proxy -b https://ark.cn-beijing.volces.com/api/v3/chat/completions \
-m glm-4-7-251222 \
-k your-api-key \
-v
# Using DeepSeek
claude-proxy -b https://api.deepseek.com/chat/completions \
-m deepseek-chat \
-k your-api-key
```
### Method 2: Environment Variables
```bash
export CLAUDE_PROXY_BACKEND="https://ark.cn-beijing.volces.com/api/v3/chat/completions"
export CLAUDE_PROXY_API_KEY="your-api-key"
export CLAUDE_PROXY_MODEL="glm-4-7-251222"
claude-proxy -v
```
### Method 3: Config File (Inside Container)
Create config file at `/workspace/.claude-proxy.json`:
```json
{
"backend": "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
"api_key": "your-api-key",
"model": "glm-4-7-251222"
}
```
Then run:
```bash
start-claude-proxy
```
## Container Usage
### Docker Run (via Environment Variables)
```bash
docker run -d \
-e CLAUDE_PROXY_BACKEND="https://ark.cn-beijing.volces.com/api/v3/chat/completions" \
-e CLAUDE_PROXY_API_KEY="your-api-key" \
-e CLAUDE_PROXY_MODEL="your-model-name" \
yaoapp/sandbox-claude:latest
```
The container will automatically start claude-proxy on startup.
### Using claude-run Wrapper
```bash
# Enter the container
docker exec -it <container> bash
# Set environment variables
export CLAUDE_PROXY_BACKEND="https://ark.cn-beijing.volces.com/api/v3/chat/completions"
export CLAUDE_PROXY_API_KEY="your-api-key"
export CLAUDE_PROXY_MODEL="your-model-name"
# Use claude-run wrapper (auto-starts proxy)
claude-run --dangerously-skip-permissions "Build me a website"
```
### Direct Claude CLI Usage
```bash
# Ensure proxy is running
curl http://127.0.0.1:3456/health
# Set Claude CLI environment
export ANTHROPIC_BASE_URL=http://127.0.0.1:3456
export ANTHROPIC_API_KEY=dummy
# Use Claude CLI
claude -p --dangerously-skip-permissions --permission-mode bypassPermissions "Build me a website"
```
## Claude CLI Common Options
```bash
# Basic usage (max permissions, no questions)
claude -p --dangerously-skip-permissions --permission-mode bypassPermissions "your task"
# Streaming JSON output
claude -p --dangerously-skip-permissions --output-format stream-json --verbose "your task"
# Interactive mode (real-time streaming output)
claude --dangerously-skip-permissions "your task"
```
### Option Reference
| Option | Description |
| ------------------------------------- | ----------------------------------------- |
| `-p, --print` | Print mode, exit after output |
| `--dangerously-skip-permissions` | Skip all permission checks |
| `--permission-mode bypassPermissions` | Bypass permission mode |
| `--output-format stream-json` | Output JSON stream |
| `--verbose` | Verbose output (required for stream-json) |
## Viewing Logs
```bash
# View proxy logs inside container
tail -f /workspace/proxy.log
# Check health status
curl http://127.0.0.1:3456/health
```
## Supported Backends
| Backend | API URL |
| ---------------------------- | ----------------------------------------------------------- |
| Volcengine GLM | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` |
| Volcengine DeepSeek | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` |
| DeepSeek Official | `https://api.deepseek.com/chat/completions` |
| OpenAI | `https://api.openai.com/v1/chat/completions` |
| Other OpenAI-compatible APIs | Custom URL |
## API Endpoints
### POST /v1/messages
Main endpoint, accepts Anthropic Messages API format requests.
### GET /health
Health check endpoint, returns `{"status": "ok"}`.
## Building
```bash
# Local build
go build -o claude-proxy ./cmd/claude-proxy/
# Cross-compile
GOOS=linux GOARCH=amd64 go build -o claude-proxy-amd64 ./cmd/claude-proxy/
GOOS=linux GOARCH=arm64 go build -o claude-proxy-arm64 ./cmd/claude-proxy/
```
## Yao Integration
Yao's sandbox executor automatically:
1. Writes connector config to `/workspace/.claude-proxy.json` when creating container
2. Calls `start-claude-proxy` to start the proxy
3. Sets `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` environment variables
4. Executes Claude CLI commands
No manual configuration required.

View file

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

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

@ -0,0 +1,461 @@
package proxy
import (
"encoding/json"
"fmt"
"strings"
)
// convertRequest converts an Anthropic request to OpenAI format
func (s *Server) convertRequest(req *AnthropicRequest) *OpenAIRequest {
// Get max_tokens from options if specified, otherwise use request value
maxTokens := req.MaxTokens
if s.config.Options != nil {
if mt, ok := s.config.Options["max_tokens"]; ok {
switch v := mt.(type) {
case float64:
maxTokens = int(v)
case int:
maxTokens = v
}
}
}
// Get temperature from options if specified
temperature := req.Temperature
if s.config.Options != nil {
if temp, ok := s.config.Options["temperature"]; ok {
if v, ok := temp.(float64); ok {
temperature = &v
}
}
}
openaiReq := &OpenAIRequest{
Model: s.config.Model,
MaxTokens: maxTokens,
Stream: req.Stream,
Temperature: temperature,
TopP: req.TopP,
Stop: req.StopSequences,
}
// Pass through extra options (e.g., thinking, reasoning_effort, etc.)
// These are backend-specific parameters that will be merged into the request
if s.config.Options != nil {
openaiReq.ExtraOptions = make(map[string]interface{})
for k, v := range s.config.Options {
// Skip standard fields that are already handled
switch k {
case "max_tokens", "temperature", "model", "key", "proxy":
continue
default:
openaiReq.ExtraOptions[k] = v
}
}
}
// 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 {
// Skip billing headers and other metadata
if strings.HasPrefix(text, "x-anthropic-") {
continue
}
texts = append(texts, text)
}
}
}
// Concatenate all system texts with newlines
if len(texts) > 0 {
return strings.Join(texts, "\n\n")
}
}
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
}
}

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

@ -0,0 +1,551 @@
// 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
Options map[string]interface{} // Extra options to pass to backend (e.g., thinking, max_tokens)
}
// 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)
if len(config.Options) > 0 {
optBytes, _ := json.Marshal(config.Options)
log.Printf("Options: %s", string(optBytes))
}
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
}
// Parse extra options from environment variable (JSON format)
// Example: CLAUDE_PROXY_OPTIONS='{"thinking":{"type":"enabled"},"max_tokens":65536}'
if optionsStr := os.Getenv("CLAUDE_PROXY_OPTIONS"); optionsStr != "" {
var options map[string]interface{}
if err := json.Unmarshal([]byte(optionsStr), &options); err != nil {
log.Printf("Warning: failed to parse CLAUDE_PROXY_OPTIONS: %v", err)
} else {
config.Options = options
}
}
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"
}
}

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

@ -0,0 +1,293 @@
package proxy
import "encoding/json"
// ============================================
// 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
// Extra options for backend-specific parameters (e.g., thinking for GLM-4)
// These are merged into the final JSON request
ExtraOptions map[string]interface{} `json:"-"`
}
// MarshalJSON custom marshaler to merge ExtraOptions into the request
func (r OpenAIRequest) MarshalJSON() ([]byte, error) {
// Create a map with standard fields
m := map[string]interface{}{
"model": r.Model,
"messages": r.Messages,
}
if r.MaxTokens > 0 {
m["max_tokens"] = r.MaxTokens
}
if r.Stream {
m["stream"] = r.Stream
}
if r.StreamOptions != nil {
m["stream_options"] = r.StreamOptions
}
if r.Temperature != nil {
m["temperature"] = *r.Temperature
}
if r.TopP != nil {
m["top_p"] = *r.TopP
}
if len(r.Stop) > 0 {
m["stop"] = r.Stop
}
if len(r.Tools) > 0 {
m["tools"] = r.Tools
}
if r.ToolChoice != nil {
m["tool_choice"] = r.ToolChoice
}
// Merge extra options (backend-specific parameters like thinking, etc.)
for k, v := range r.ExtraOptions {
// Don't override standard fields
if _, exists := m[k]; !exists {
m[k] = v
}
}
return json.Marshal(m)
}
// 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
}