diff --git a/.gitignore b/.gitignore index 4bba6f20..930b708f 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ agent/test/UPGRADE_PLAN.md introduction/* !sandbox/docker/build.sh sandbox/docker/yao-bridge-* +sandbox/docker/claude-proxy-* +sandbox/docker/claude/claude-proxy-* +sandbox/proxy/claude-proxy-linux-* diff --git a/agent/assistant/agent.go b/agent/assistant/agent.go index 1a02197d..9c448c0b 100644 --- a/agent/assistant/agent.go +++ b/agent/assistant/agent.go @@ -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 var sandboxExecutor agentsandbox.Executor var sandboxCleanup func() + var sandboxLoadingMsgID string if ast.HasSandbox() { ctx.Logger.Phase("Sandbox") var err error - sandboxExecutor, sandboxCleanup, err = ast.initSandbox(ctx, opts) + sandboxExecutor, sandboxCleanup, sandboxLoadingMsgID, err = ast.initSandbox(ctx, opts) if err != nil { ast.traceAgentFail(agentNode, 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 if ast.HasSandbox() { // 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 { // Direct LLM execution path completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts) diff --git a/agent/assistant/sandbox.go b/agent/assistant/sandbox.go index 66e5733b..60750589 100644 --- a/agent/assistant/sandbox.go +++ b/agent/assistant/sandbox.go @@ -12,6 +12,7 @@ import ( gouMCP "github.com/yaoapp/gou/mcp" mcpProcess "github.com/yaoapp/gou/mcp/process" "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/output/message" agentsandbox "github.com/yaoapp/yao/agent/sandbox" "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 // This is called BEFORE hooks so that hooks can access ctx.sandbox // 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) manager, err := GetSandboxManager() if err != nil { 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 { - return nil, nil, fmt.Errorf("sandbox manager not initialized") + return nil, nil, "", fmt.Errorf("sandbox manager not initialized") } // Build executor options from assistant config execOpts, err := ast.buildSandboxOptions(ctx, opts) if err != nil { 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 @@ -86,7 +87,7 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) ( loadingMsg := &message.Message{ Type: message.TypeLoading, Props: map[string]interface{}{ - "message": "Preparing sandbox environment...", + "message": i18n.T(ctx.Locale, "sandbox.preparing"), }, } loadingMsgID, _ := ctx.SendStream(loadingMsg) @@ -98,11 +99,21 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) ( if traceErr == nil && trace != nil { trace.Error("Sandbox creation failed: %v", err) } - // End loading message + // End loading message with done:true 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, + }, + } + ctx.Send(doneMsg) } - return nil, nil, fmt.Errorf("failed to create sandbox executor: %w", err) + return nil, nil, "", fmt.Errorf("failed to create sandbox executor: %w", err) } // Log sandbox ready @@ -111,11 +122,6 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) ( trace.Info("Sandbox container ready") } - // End loading message - if loadingMsgID != "" { - ctx.End(loadingMsgID) - } - // Return cleanup function cleanup := func() { 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.) @@ -135,6 +143,7 @@ func (ast *Assistant) executeSandboxStream( agentNode traceTypes.Node, streamHandler message.StreamFunc, executor agentsandbox.Executor, + loadingMsgID string, ) (*context.CompletionResponse, error) { // Mark the agentNode as used to avoid unused variable error @@ -147,9 +156,41 @@ func (ast *Assistant) executeSandboxStream( // Log sandbox execution 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 + // 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) + 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) } @@ -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 conn, _, err := ast.GetConnector(ctx, opts) if err != nil { @@ -215,6 +268,40 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op 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 if ast.MCP != nil && len(ast.MCP.Servers) > 0 { // Build MCP config for Claude CLI @@ -354,3 +441,21 @@ func (ast *Assistant) BuildMCPConfigForSandbox(ctx *context.Context) ([]byte, er 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 +} diff --git a/agent/assistant/sandbox_integration_test.go b/agent/assistant/sandbox_integration_test.go index 4852f9dd..d9276b57 100644 --- a/agent/assistant/sandbox_integration_test.go +++ b/agent/assistant/sandbox_integration_test.go @@ -78,28 +78,35 @@ func TestClaudeCommandBuilding(t *testing.T) { require.NoError(t, err) // 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.Equal(t, "bash", cmd[0], "Command should start with bash") 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], "--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) - // Verify environment variables + // Verify environment variables (claude-proxy mode) assert.NotEmpty(t, env) - assert.Equal(t, "https://ark.cn-beijing.volces.com/api/v3", env["CCR_API_BASE"]) - assert.Equal(t, "test-api-key", env["CCR_API_KEY"]) - assert.Equal(t, "ep-xxxxx", env["CCR_MODEL"]) - assert.Equal(t, "10", env["CLAUDE_MAX_TURNS"]) - assert.Equal(t, "acceptEdits", env["CLAUDE_PERMISSION_MODE"]) - assert.Equal(t, "stream-json", env["CLAUDE_OUTPUT_FORMAT"]) - assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a helpful coding assistant") + assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"], "Should set proxy base URL") + assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"], "Should set dummy API key for proxy") + + // max_turns is passed via CLI flag + // system prompt is written to file via heredoc, then referenced via --append-system-prompt-file + assert.Contains(t, cmd[2], "--max-turns", "Should include max-turns flag") + 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) } -// TestClaudeCCRConfigBuilding tests that CCR config is correctly built -func TestClaudeCCRConfigBuilding(t *testing.T) { +// TestClaudeProxyConfigBuilding tests that claude-proxy config is correctly built +func TestClaudeProxyConfigBuilding(t *testing.T) { test.Prepare(t, config.Conf) defer test.Clean() @@ -109,20 +116,20 @@ func TestClaudeCCRConfigBuilding(t *testing.T) { Model: "ep-xxxxx", } - configJSON, err := claude.BuildCCRConfig(opts) + configJSON, err := claude.BuildProxyConfig(opts) require.NoError(t, err) 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) - assert.Contains(t, string(configJSON), "api_base_url") + // Verify the JSON contains expected fields for claude-proxy + assert.Contains(t, string(configJSON), "backend") assert.Contains(t, string(configJSON), "api_key") - assert.Contains(t, string(configJSON), "models") - // Verify CCR format fields - assert.Contains(t, string(configJSON), "Providers") - assert.Contains(t, string(configJSON), "Router") - assert.Contains(t, string(configJSON), "volcengine") + assert.Contains(t, string(configJSON), "model") + assert.Contains(t, string(configJSON), "test-api-key") + assert.Contains(t, string(configJSON), "ep-xxxxx") + // Backend URL should end with /chat/completions + assert.Contains(t, string(configJSON), "/chat/completions") } // TestDefaultImageSelection tests that default images are correctly selected diff --git a/agent/caller/sandbox_integration_test.go b/agent/caller/sandbox_integration_test.go new file mode 100644 index 00000000..6730e50d --- /dev/null +++ b/agent/caller/sandbox_integration_test.go @@ -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] + "..." +} diff --git a/agent/i18n/builtin.go b/agent/i18n/builtin.go index eb9e0568..c73bac47 100644 --- a/agent/i18n/builtin.go +++ b/agent/i18n/builtin.go @@ -99,14 +99,39 @@ func init() { "kb.chat.name": "Chat Knowledge Base", "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.image.analyzing": "Analyzing image...", + "content.image.analyzing": "Analyzing image", // 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.loading": "Searching...", + "search.loading": "Searching", "search.success": "Found %d references", "search.success.one": "Found 1 reference", "search.partial": "Found %d references (some sources failed)", @@ -114,12 +139,12 @@ func init() { "search.no_results": "No references found", // Search Intent: assistant/search.go - Intent detection messages - "search.intent.loading": "Checking if references are needed...", - "search.intent.need_search": "Searching for references...", + "search.intent.loading": "Checking if references are needed", + "search.intent.need_search": "Searching for references", "search.intent.no_search": "No references needed", // Keyword Extraction: assistant/search.go - Keyword extraction messages - "search.keyword.loading": "Analyzing conversation...", + "search.keyword.loading": "Analyzing conversation", "search.keyword.done": "Analysis complete", // Search: assistant/search.go - Trace labels @@ -198,14 +223,39 @@ func init() { "kb.chat.name": "聊天知识库", "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.image.analyzing": "正在分析图片...", + "content.image.analyzing": "正在分析图片", // 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.loading": "正在搜索...", + "search.loading": "正在搜索", "search.success": "找到 %d 条参考资料", "search.success.one": "找到 1 条参考资料", "search.partial": "找到 %d 条参考资料(部分来源失败)", @@ -213,12 +263,12 @@ func init() { "search.no_results": "未找到相关资料", // Search Intent: assistant/search.go - Intent detection messages - "search.intent.loading": "检查是否需要查询资料...", - "search.intent.need_search": "正在查询相关资料...", + "search.intent.loading": "检查是否需要查询资料", + "search.intent.need_search": "正在查询相关资料", "search.intent.no_search": "无需查询资料", // Keyword Extraction: assistant/search.go - Keyword extraction messages - "search.keyword.loading": "正在分析对话内容...", + "search.keyword.loading": "正在分析对话内容", "search.keyword.done": "分析完成", // Search: assistant/search.go - Trace labels @@ -325,14 +375,39 @@ func init() { "kb.chat.name": "聊天知识库", "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.image.analyzing": "正在分析图片...", + "content.image.analyzing": "正在分析图片", // 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.loading": "正在搜索...", + "search.loading": "正在搜索", "search.success": "找到 %d 条参考资料", "search.success.one": "找到 1 条参考资料", "search.partial": "找到 %d 条参考资料(部分来源失败)", @@ -340,12 +415,12 @@ func init() { "search.no_results": "未找到相关资料", // Search Intent: assistant/search.go - Intent detection messages - "search.intent.loading": "检查是否需要查询资料...", - "search.intent.need_search": "正在查询相关资料...", + "search.intent.loading": "检查是否需要查询资料", + "search.intent.need_search": "正在查询相关资料", "search.intent.no_search": "无需查询资料", // Keyword Extraction: assistant/search.go - Keyword extraction messages - "search.keyword.loading": "正在分析对话内容...", + "search.keyword.loading": "正在分析对话内容", "search.keyword.done": "分析完成", // Search: assistant/search.go - Trace labels diff --git a/agent/sandbox/claude/command.go b/agent/sandbox/claude/command.go index 5409807f..37cdca09 100644 --- a/agent/sandbox/claude/command.go +++ b/agent/sandbox/claude/command.go @@ -8,44 +8,117 @@ import ( 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 +// Uses stdin with --input-format stream-json for unlimited prompt length func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) { // Build system prompt from conversation history - systemPrompt, userPrompt := buildPrompts(messages) + systemPrompt, _ := buildPrompts(messages) - // Build the ccr code command with all arguments - // We use bash -c to ensure CCR is started first, then run ccr code with proper argument handling - var ccrArgs []string + // Inject sandbox environment prompt + if systemPrompt != "" { + 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) - permMode := "acceptEdits" // default + permMode := "bypassPermissions" // default for sandbox if opts != nil && opts.Arguments != nil { if mode, ok := opts.Arguments["permission_mode"].(string); ok && mode != "" { permMode = mode } } - ccrArgs = append(ccrArgs, "--permission-mode", permMode) + claudeArgs = append(claudeArgs, "--dangerously-skip-permissions") + claudeArgs = append(claudeArgs, "--permission-mode", permMode) + + // Add 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 if opts != nil && len(opts.MCPConfig) > 0 { - ccrArgs = append(ccrArgs, "--mcp-config", "/workspace/.mcp.json") + claudeArgs = append(claudeArgs, "--mcp-config", "/workspace/.mcp.json") // Allow all tools from the "yao" MCP server - ccrArgs = append(ccrArgs, "--allowedTools", "mcp__yao__*") + claudeArgs = append(claudeArgs, "--allowedTools", "mcp__yao__*") } // Build the full bash command - // Start CCR daemon, wait, then run ccr code with arguments - bashCmd := "nohup ccr start >/dev/null 2>&1 & sleep 2; ccr code" - for _, arg := range ccrArgs { - // Quote arguments that might contain special characters - bashCmd += fmt.Sprintf(" %q", arg) - } - bashCmd += " -p" - if userPrompt != "" { - bashCmd += fmt.Sprintf(" %q", userPrompt) + // Use heredoc for both system prompt and input JSONL to avoid shell escaping issues + // System prompt may contain quotes, newlines, special characters that break shell quoting + var bashCmd strings.Builder + + // If we have a system prompt, write it to a temp file via heredoc first + // then use --append-system-prompt-file + if systemPrompt != "" { + bashCmd.WriteString("cat << 'PROMPTEOF' > /tmp/.system-prompt.txt\n") + 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 env := buildEnvironment(opts, systemPrompt) @@ -53,6 +126,44 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map 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 func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) { var systemParts []string @@ -123,118 +234,54 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string { return env } - // CCR configuration via environment - // CCR (Claude Code Router) transforms OpenAI-compatible API to Anthropic API format - if opts.ConnectorHost != "" { - // CCR expects ANTHROPIC_BASE_URL but will proxy through its own router - env["CCR_API_BASE"] = opts.ConnectorHost - } + // claude-proxy runs on localhost:3456, Claude CLI connects to it + env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456" + env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this - if opts.ConnectorKey != "" { - env["CCR_API_KEY"] = opts.ConnectorKey - } - - if opts.Model != "" { - env["CCR_MODEL"] = opts.Model - } - - // Set system prompt via environment (Claude CLI supports this) - 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" - } + // Note: System prompt and max_turns are passed via CLI flags in BuildCommand + // CLAUDE_SYSTEM_PROMPT environment variable is NOT supported by Claude CLI + // --append-system-prompt or --system-prompt flags must be used instead return env } -// BuildCCRConfig builds the CCR (Claude Code Router) configuration JSON -// CCR requires a specific format with Providers array and Router configuration -func BuildCCRConfig(opts *Options) ([]byte, error) { +// BuildProxyConfig builds the claude-proxy configuration JSON +// This config file is read by start-claude-proxy script in the container +// Config is written to /tmp/.yao/proxy.json (not /workspace/) for security +func BuildProxyConfig(opts *Options) ([]byte, error) { if opts == nil { return nil, fmt.Errorf("options is required") } - // Determine provider name based on host - providerName := "custom" - apiBaseURL := opts.ConnectorHost - needsTransformer := false - - if strings.Contains(opts.ConnectorHost, "volces.com") || strings.Contains(opts.ConnectorHost, "volcengine") { - providerName = "volcengine" - needsTransformer = true - // Ensure URL ends with chat/completions - if !strings.HasSuffix(apiBaseURL, "/chat/completions") { - apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/chat/completions" - } - } else if strings.Contains(opts.ConnectorHost, "deepseek") { - providerName = "deepseek" - needsTransformer = true - if !strings.HasSuffix(apiBaseURL, "/chat/completions") { - apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/chat/completions" - } - } else if strings.Contains(opts.ConnectorHost, "openai.com") { - providerName = "openai" - if !strings.HasSuffix(apiBaseURL, "/chat/completions") { - apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/v1/chat/completions" - } - } else if strings.Contains(opts.ConnectorHost, "anthropic.com") { - providerName = "claude" + // Build backend URL - ensure it ends with /chat/completions + backendURL := opts.ConnectorHost + if !strings.HasSuffix(backendURL, "/chat/completions") { + backendURL = strings.TrimSuffix(backendURL, "/") + "/chat/completions" } - // Build provider configuration - provider := map[string]interface{}{ - "name": providerName, - "api_base_url": apiBaseURL, - "api_key": opts.ConnectorKey, - "models": []string{opts.Model}, - } - - // Add transformer for providers that need it (DeepSeek, Volcengine) - if needsTransformer { - provider["transformer"] = map[string]interface{}{ - "use": []interface{}{ - []interface{}{"maxtoken", map[string]interface{}{"max_tokens": 16384}}, - }, - } - } - - // Build router configuration - routerKey := fmt.Sprintf("%s,%s", providerName, opts.Model) - router := map[string]interface{}{ - "default": routerKey, - "background": routerKey, - "think": routerKey, - } - - // Build full config config := map[string]interface{}{ - "LOG": true, - "API_TIMEOUT_MS": 600000, - "NON_INTERACTIVE_MODE": true, - "Providers": []interface{}{provider}, - "Router": router, + "backend": backendURL, + "api_key": opts.ConnectorKey, + "model": opts.Model, + } + + // 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, "", " ") } + +// BuildCCRConfig is deprecated, kept for backward compatibility +// Use BuildProxyConfig instead +func BuildCCRConfig(opts *Options) ([]byte, error) { + return BuildProxyConfig(opts) +} diff --git a/agent/sandbox/claude/command_test.go b/agent/sandbox/claude/command_test.go index 32a9612a..a35876d5 100644 --- a/agent/sandbox/claude/command_test.go +++ b/agent/sandbox/claude/command_test.go @@ -1,6 +1,7 @@ package claude import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -24,16 +25,21 @@ func TestBuildCommand(t *testing.T) { require.NoError(t, err) // 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, "-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 - assert.Equal(t, "https://api.example.com", env["CCR_API_BASE"]) - assert.Equal(t, "key123", env["CCR_API_KEY"]) - assert.Equal(t, "test-model", env["CCR_MODEL"]) - assert.Equal(t, "stream-json", env["CLAUDE_OUTPUT_FORMAT"]) + // Verify environment variables (claude-proxy) + assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"]) + assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"]) } func TestBuildCommandWithSystemPrompt(t *testing.T) { @@ -46,12 +52,35 @@ func TestBuildCommandWithSystemPrompt(t *testing.T) { opts := &Options{} - _, env, err := BuildCommand(messages, opts) + cmd, _, err := BuildCommand(messages, opts) require.NoError(t, err) - // System prompt should include conversation history - assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a code reviewer") - assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "Conversation History") + // System prompt should be written to file via heredoc, then passed via --append-system-prompt-file + bashCmd := cmd[2] // The bash -c command string + 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) { @@ -63,59 +92,115 @@ func TestBuildCommandWithArguments(t *testing.T) { Arguments: map[string]interface{}{ "max_turns": 20, "permission_mode": "acceptEdits", - "output_format": "json", }, } - _, env, err := BuildCommand(messages, opts) + cmd, _, err := BuildCommand(messages, opts) require.NoError(t, err) - assert.Equal(t, "20", env["CLAUDE_MAX_TURNS"]) - assert.Equal(t, "acceptEdits", env["CLAUDE_PERMISSION_MODE"]) - assert.Equal(t, "json", env["CLAUDE_OUTPUT_FORMAT"]) + bashCmd := cmd[2] // The bash -c command string + // max_turns should be in command args via --max-turns + 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{ ConnectorHost: "https://api.example.com", ConnectorKey: "key123", Model: "test-model", } - configJSON, err := BuildCCRConfig(opts) + configJSON, err := BuildProxyConfig(opts) require.NoError(t, err) configStr := string(configJSON) - // CCR config uses snake_case for fields - assert.Contains(t, configStr, "api_base_url") - assert.Contains(t, configStr, "https://api.example.com") + // Proxy config uses simple format + assert.Contains(t, configStr, "backend") + assert.Contains(t, configStr, "https://api.example.com/chat/completions") assert.Contains(t, configStr, "api_key") assert.Contains(t, configStr, "key123") - assert.Contains(t, configStr, "models") + assert.Contains(t, configStr, "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{ ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3/", ConnectorKey: "test-key", Model: "ep-xxx", } - configJSON, err := BuildCCRConfig(opts) + configJSON, err := BuildProxyConfig(opts) require.NoError(t, err) 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 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) { @@ -137,3 +222,19 @@ func TestGetMessageContent(t *testing.T) { assert.Contains(t, getMessageContent(msg3), "Part 1") 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 +} diff --git a/agent/sandbox/claude/e2e_test.go b/agent/sandbox/claude/e2e_test.go new file mode 100644 index 00000000..0ae12111 --- /dev/null +++ b/agent/sandbox/claude/e2e_test.go @@ -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] + "..." +} diff --git a/agent/sandbox/claude/executor.go b/agent/sandbox/claude/executor.go index eeeb6615..e028c919 100644 --- a/agent/sandbox/claude/executor.go +++ b/agent/sandbox/claude/executor.go @@ -6,10 +6,15 @@ import ( "encoding/json" "fmt" "io" + "log" + "os" + "path/filepath" "strings" "time" + goujson "github.com/yaoapp/gou/json" agentContext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/output/message" infraSandbox "github.com/yaoapp/yao/sandbox" "github.com/yaoapp/yao/sandbox/ipc" @@ -17,20 +22,23 @@ import ( // Options for Claude executor (copied from parent package to avoid import cycle) type Options struct { - Command string - Image string - MaxMemory string - MaxCPU float64 - Timeout time.Duration - Arguments map[string]interface{} - UserID string - ChatID string - MCPConfig []byte - MCPTools map[string]*ipc.MCPTool // MCP tools to expose via IPC - SkillsDir string - ConnectorHost string - ConnectorKey string - Model string + Command string + Image string + MaxMemory string + MaxCPU float64 + Timeout time.Duration + Arguments map[string]interface{} + UserID string + ChatID string + MCPConfig []byte + MCPTools map[string]*ipc.MCPTool // MCP tools to expose via IPC + SkillsDir string + SystemPrompt string // System prompt from assistant prompts.yml + ConnectorHost string + ConnectorKey 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 @@ -39,6 +47,7 @@ type Executor struct { containerName string opts *Options workDir string + loadingMsgID string // Loading message ID for tool execution updates } // NewExecutor creates a new Claude executor @@ -90,12 +99,61 @@ func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, er }, 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 func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) { - stdCtx := context.Background() - if ctx != nil && ctx.Context != nil { - stdCtx = ctx.Context - } + // Create a cancellable context for this stream operation + // We need to handle both: + // 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) 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) } + // 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 cmd, env, err := BuildCommand(messages, e.opts) if err != nil { @@ -132,18 +204,70 @@ func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Mes if err != nil { return nil, fmt.Errorf("failed to execute command: %w", err) } - defer reader.Close() - // Parse streaming output - return e.parseStream(reader, handler) + // Ensure reader is closed when context is cancelled or function returns + // 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 -// This includes: CCR config, MCP config, and Skills directory +// This includes: claude-proxy config, MCP config, and Skills directory func (e *Executor) prepareEnvironment(ctx context.Context) error { - // 1. Write CCR config (Claude Code Router configuration) - if err := e.writeCCRConfig(ctx); err != nil { - return fmt.Errorf("failed to write CCR config: %w", err) + // 1. Write claude-proxy config and start the proxy + if err := e.startClaudeProxy(ctx); err != nil { + return fmt.Errorf("failed to start claude-proxy: %w", err) } // 2. Write MCP config if provided @@ -165,20 +289,55 @@ func (e *Executor) prepareEnvironment(ctx context.Context) error { return nil } -// writeCCRConfig writes the CCR configuration file to the container -func (e *Executor) writeCCRConfig(ctx context.Context) error { - // Build CCR config - configJSON, err := BuildCCRConfig(e.opts) - if err != nil { - return fmt.Errorf("failed to build CCR config: %w", err) +// startClaudeProxy writes proxy config and starts claude-proxy +func (e *Executor) startClaudeProxy(ctx context.Context) error { + // Skip if no connector configured (e.g., test containers without claude-proxy) + if e.opts.ConnectorHost == "" || e.opts.ConnectorKey == "" { + return nil } - // Write config to container's CCR directory - configPath := "/home/sandbox/.claude-code-router/config.json" + // Build proxy config + 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 { 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 } @@ -229,8 +388,62 @@ func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Me return e.Stream(ctx, messages, nil) } -// parseStream parses Claude CLI streaming output -func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) { +// debugWriter wraps an io.Reader to write all data to a debug log file +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) // Increase buffer size for potentially large outputs 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 model string 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() { + // Check for context cancellation on each iteration + select { + case <-stdCtx.Done(): + return nil, stdCtx.Err() + default: + // Continue processing + } + line := scanner.Text() + lineCount++ if line == "" { 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) - var msg StreamMessage + var msg map[string]interface{} if err := json.Unmarshal([]byte(line), &msg); err != nil { // Not JSON, might be plain text output textContent.WriteString(line) @@ -259,24 +519,248 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a continue } - // Process different message types - switch msg.Type { - case "content_block_delta": - // Streaming text content - if delta, ok := msg.Content.(map[string]interface{}); ok { - if text, ok := delta["text"].(string); ok { - textContent.WriteString(text) - // Send to stream handler if available - if handler != nil { - handler(message.ChunkText, []byte(text)) + msgType, _ := msg["type"].(string) + + // 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": + if delta, ok := event["delta"].(map[string]interface{}); ok { + deltaType, _ := delta["type"].(string) + 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 "message_delta": - // Message completion with usage - if content, ok := msg.Content.(map[string]interface{}); ok { - if usageData, ok := content["usage"].(map[string]interface{}); ok { + 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) + // Send to stream handler if available + 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)) + } + } + } + + case "tool_use": + toolName := getString(contentItem, "name") + toolCall := agentContext.ToolCall{ + 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{} if v, ok := usageData["input_tokens"].(float64); ok { usage.PromptTokens = int(v) @@ -288,32 +772,32 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a } } - case "message_start": - // Extract model from message_start - if content, ok := msg.Content.(map[string]interface{}); ok { - if m, ok := content["model"].(string); ok { - model = m + case "result": + // Final result message + // Check if this is an error result (is_error: true) + isError, _ := msg["is_error"].(bool) + 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 } - - case "content_block_start": - // Might contain tool use blocks - 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) - } + // Send done signal to handler (only if message was started and not an error) + if handler != nil && messageStarted && !isError { + handler(message.ChunkMessageEnd, nil) } 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) } + // 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 response := &agentContext.CompletionResponse{ ID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), Model: model, Created: time.Now().Unix(), Role: "assistant", - Content: textContent.String(), + Content: content, FinishReason: agentContext.FinishReasonStop, } @@ -345,6 +850,161 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a 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 func (e *Executor) ReadFile(ctx context.Context, path string) ([]byte, error) { // Make path absolute if not diff --git a/agent/sandbox/claude/executor_test.go b/agent/sandbox/claude/executor_test.go index 212d41fe..1bbfb1e5 100644 --- a/agent/sandbox/claude/executor_test.go +++ b/agent/sandbox/claude/executor_test.go @@ -190,6 +190,7 @@ func TestClaudeExecutorMCPConfigWrite(t *testing.T) { UserID: "test-user", ChatID: fmt.Sprintf("test-chat-mcp-write-%d", time.Now().UnixNano()), MCPConfig: mcpConfig, + // No connector config - skip proxy start for alpine test image } exec, err := NewExecutor(manager, opts) @@ -245,6 +246,7 @@ func TestClaudeExecutorSkillsCopy(t *testing.T) { UserID: "test-user", ChatID: fmt.Sprintf("test-chat-skills-%d", time.Now().UnixNano()), SkillsDir: skillsDir, + // No connector config - skip proxy start for alpine test image } exec, err := NewExecutor(manager, opts) @@ -312,15 +314,13 @@ func TestClaudeExecutorPrepareEnvironmentIntegration(t *testing.T) { mcpConfig := []byte(`{"mcpServers":{"echo":{"command":"yao-mcp-proxy","args":["echo"],"tools":["ping","echo","status"]}}}`) opts := &Options{ - Command: "claude", - Image: "alpine:latest", - UserID: "test-user", - ChatID: fmt.Sprintf("test-chat-full-env-%d", time.Now().UnixNano()), - ConnectorHost: "https://api.test.com", - ConnectorKey: "test-key", - Model: "test-model", - MCPConfig: mcpConfig, - SkillsDir: skillsDir, + Command: "claude", + Image: "alpine:latest", + UserID: "test-user", + ChatID: fmt.Sprintf("test-chat-full-env-%d", time.Now().UnixNano()), + MCPConfig: mcpConfig, + SkillsDir: skillsDir, + // No connector config - skip proxy start for alpine test image } exec, err := NewExecutor(manager, opts) @@ -333,26 +333,19 @@ func TestClaudeExecutorPrepareEnvironmentIntegration(t *testing.T) { err = exec.prepareEnvironment(ctx) require.NoError(t, err, "prepareEnvironment should succeed") - // Verify all files exist - // 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 + // 1. Check MCP config mcpContent, err := exec.ReadFile(ctx, ".mcp.json") require.NoError(t, err, "MCP config should exist in container") assert.JSONEq(t, string(mcpConfig), string(mcpContent), "MCP config content should match") 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"}) require.NoError(t, err, "Skills directory should exist in container") assert.Contains(t, output, "echo-test", "echo-test skill should exist") 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") require.NoError(t, err, "SKILL.md should exist in container") require.NotEmpty(t, skillContent, "SKILL.md should not be empty") diff --git a/agent/sandbox/claude/real_e2e_test.go b/agent/sandbox/claude/real_e2e_test.go new file mode 100644 index 00000000..50c8f2e6 --- /dev/null +++ b/agent/sandbox/claude/real_e2e_test.go @@ -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") + } +} diff --git a/agent/sandbox/executor.go b/agent/sandbox/executor.go index 550cf24c..89ba56a6 100644 --- a/agent/sandbox/executor.go +++ b/agent/sandbox/executor.go @@ -26,20 +26,23 @@ func New(manager *infraSandbox.Manager, opts *Options) (Executor, error) { case "claude": // Convert to claude.Options claudeOpts := &claude.Options{ - Command: opts.Command, - Image: opts.Image, - MaxMemory: opts.MaxMemory, - MaxCPU: opts.MaxCPU, - Timeout: opts.Timeout, - Arguments: opts.Arguments, - UserID: opts.UserID, - ChatID: opts.ChatID, - MCPConfig: opts.MCPConfig, - MCPTools: opts.MCPTools, // MCP tools to expose via IPC - SkillsDir: opts.SkillsDir, - ConnectorHost: opts.ConnectorHost, - ConnectorKey: opts.ConnectorKey, - Model: opts.Model, + Command: opts.Command, + Image: opts.Image, + MaxMemory: opts.MaxMemory, + MaxCPU: opts.MaxCPU, + Timeout: opts.Timeout, + Arguments: opts.Arguments, + UserID: opts.UserID, + ChatID: opts.ChatID, + MCPConfig: opts.MCPConfig, + MCPTools: opts.MCPTools, + SkillsDir: opts.SkillsDir, + SystemPrompt: opts.SystemPrompt, // Required for Claude CLI execution + ConnectorHost: opts.ConnectorHost, + ConnectorKey: opts.ConnectorKey, + 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) case "cursor": diff --git a/agent/sandbox/types.go b/agent/sandbox/types.go index cd413ba7..ea17f710 100644 --- a/agent/sandbox/types.go +++ b/agent/sandbox/types.go @@ -18,6 +18,9 @@ type Executor interface { // 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) + // SetLoadingMsgID sets the loading message ID for tool execution status updates + SetLoadingMsgID(id string) + // Filesystem operations (for Hooks) ReadFile(ctx context.Context, path string) ([]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/ 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 // e.g., connectors/deepseek/v3.conn.yao → host, key, model ConnectorHost string `json:"-"` ConnectorKey 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 diff --git a/agent/store/types/types.go b/agent/store/types/types.go index 657c8e8e..732a1af9 100644 --- a/agent/store/types/types.go +++ b/agent/store/types/types.go @@ -370,6 +370,7 @@ type Sandbox struct { MaxCPU float64 `json:"max_cpu,omitempty"` // CPU limit (e.g., 2.0) Timeout string `json:"timeout,omitempty"` // Execution timeout (e.g., "10m") 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 diff --git a/sandbox/docker/build.sh b/sandbox/docker/build.sh index 891d6e3a..af99e3bd 100755 --- a/sandbox/docker/build.sh +++ b/sandbox/docker/build.sh @@ -30,6 +30,21 @@ echo "Built: yao-bridge-amd64, yao-bridge-arm64" cd "$SCRIPT_DIR" +# Build claude-proxy for both architectures +echo "" +echo "=== Building claude-proxy (multi-arch) ===" +cd "$SCRIPT_DIR/../proxy/cmd/claude-proxy" + +echo "Building for linux/amd64..." +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/claude-proxy-amd64" . + +echo "Building for linux/arm64..." +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/claude-proxy-arm64" . + +echo "Built: claude-proxy-amd64, claude-proxy-arm64" + +cd "$SCRIPT_DIR" + # Check if buildx is available and set up setup_buildx() { echo "" @@ -141,4 +156,5 @@ docker images | grep -E "(sandbox-base|sandbox-claude|sandbox-cursor)" | head -1 echo "" echo "=== Cleanup ===" rm -f "$SCRIPT_DIR/yao-bridge-amd64" "$SCRIPT_DIR/yao-bridge-arm64" +rm -f "$SCRIPT_DIR/claude-proxy-amd64" "$SCRIPT_DIR/claude-proxy-arm64" echo "Removed temporary binary files" diff --git a/sandbox/docker/claude/Dockerfile b/sandbox/docker/claude/Dockerfile index 6ecb4e90..429e0216 100644 --- a/sandbox/docker/claude/Dockerfile +++ b/sandbox/docker/claude/Dockerfile @@ -1,4 +1,4 @@ -# Claude sandbox image: Claude CLI + Node.js + Python + CCR +# Claude sandbox image: Claude CLI + Node.js + Python + claude-proxy # Supports both amd64 and arm64 architectures # Base: Ubuntu 24.04 LTS ARG REGISTRY=yaoapp @@ -25,6 +25,14 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y nodejs \ && 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 RUN mkdir -p /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 || \ echo "Claude CLI installation skipped (may not be available yet)" -# Install Claude Code Router (CCR) for third-party LLM support -# Supports: DeepSeek, GLM, Volcengine, OpenRouter, etc. -RUN npm install -g @musistudio/claude-code-router +# Create Claude CLI configuration for auto-approve all operations +# This is CRITICAL for non-interactive sandbox usage +# 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 -RUN cat > /usr/local/bin/ccr-run << 'SCRIPT' + +# Install claude-proxy (architecture-specific binary) +ARG TARGETARCH +COPY claude-proxy-${TARGETARCH} /usr/local/bin/claude-proxy +RUN chmod +x /usr/local/bin/claude-proxy + +# Create claude-proxy startup script +RUN cat > /usr/local/bin/start-proxy << 'SCRIPT' #!/bin/bash -# CCR wrapper: starts CCR daemon and runs ccr code -# Usage: ccr-run "prompt" or ccr-run -c /path/to/config.json "prompt" +# Claude Proxy startup script +# Usage: start-proxy [options] +# Options are passed directly to claude-proxy -CONFIG="" -while [[ $# -gt 0 ]]; do - case $1 in - -c|--config) - CONFIG="$2" - shift 2 - ;; - *) - break - ;; - esac -done +LOG_DIR="${WORKSPACE:-/workspace}" +LOG_FILE="${LOG_DIR}/proxy.log" -# Apply config if provided -if [ -n "$CONFIG" ] && [ -f "$CONFIG" ]; then - cp "$CONFIG" ~/.claude-code-router/config.json +# Ensure log directory exists +mkdir -p "$LOG_DIR" 2>/dev/null || true + +# Default environment variables (can be overridden) +export CLAUDE_PROXY_PORT="${CLAUDE_PROXY_PORT:-3456}" + +# Start proxy with logging +exec /usr/local/bin/claude-proxy -v -l "$LOG_FILE" "$@" +SCRIPT +RUN chmod +x /usr/local/bin/start-proxy + +# Create claude-run wrapper for easy usage (manual mode) +RUN cat > /usr/local/bin/claude-run << 'SCRIPT' +#!/bin/bash +# Claude CLI wrapper with proxy auto-start +# Usage: claude-run [claude options] "prompt" +# +# Environment variables: +# CLAUDE_PROXY_BACKEND - Backend API URL (required) +# CLAUDE_PROXY_API_KEY - Backend API Key (required) +# CLAUDE_PROXY_MODEL - Backend model name (required) +# CLAUDE_PROXY_PORT - Proxy port (default: 3456) +# WORKSPACE - Working directory (default: /workspace) + +set -e + +# Check required environment variables +if [ -z "$CLAUDE_PROXY_BACKEND" ]; then + echo "Error: CLAUDE_PROXY_BACKEND is not set" + echo "Example: export CLAUDE_PROXY_BACKEND=https://ark.cn-beijing.volces.com/api/v3/chat/completions" + exit 1 fi -# Start CCR daemon (nohup because ccr start -d doesn't work in containers) -nohup ccr start >/dev/null 2>&1 & -sleep 2 +if [ -z "$CLAUDE_PROXY_API_KEY" ]; then + echo "Error: CLAUDE_PROXY_API_KEY is not set" + exit 1 +fi -# Run ccr code -exec ccr code -p "$*" +if [ -z "$CLAUDE_PROXY_MODEL" ]; then + echo "Error: CLAUDE_PROXY_MODEL is not set" + echo "Example: export CLAUDE_PROXY_MODEL=glm-4-7-251222" + exit 1 +fi + +PORT="${CLAUDE_PROXY_PORT:-3456}" +WORKSPACE="${WORKSPACE:-/workspace}" +LOG_FILE="${WORKSPACE}/proxy.log" + +# Check if proxy is already running +if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then + echo "Proxy already running on port $PORT" +else + echo "Starting claude-proxy..." + mkdir -p "$WORKSPACE" 2>/dev/null || true + nohup /usr/local/bin/claude-proxy -v -l "$LOG_FILE" > /dev/null 2>&1 & + + # Wait for proxy to start + for i in {1..10}; do + if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then + echo "Proxy started successfully" + break + fi + sleep 0.5 + done + + if ! curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then + echo "Error: Failed to start proxy" + exit 1 + fi +fi + +# Set Claude CLI environment +export ANTHROPIC_BASE_URL="http://127.0.0.1:${PORT}" +export ANTHROPIC_API_KEY="dummy" + +# Change to workspace directory +cd "$WORKSPACE" + +# Run Claude CLI with all arguments +exec claude "$@" SCRIPT -RUN chmod +x /usr/local/bin/ccr-run +RUN chmod +x /usr/local/bin/claude-run + +# Create start-claude-proxy script for programmatic use (called by Yao) +# 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 # Verify installations RUN node --version && npm --version && python3 --version && \ claude --version || true && \ - ccr --version || true + claude-proxy --help 2>&1 | head -1 || true WORKDIR /workspace +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["sleep", "infinity"] diff --git a/sandbox/manager.go b/sandbox/manager.go index 161f0e72..7921bd5c 100644 --- a/sandbox/manager.go +++ b/sandbox/manager.go @@ -42,6 +42,8 @@ type demuxReadCloser struct { closer io.Closer done chan struct{} err error + closed bool + mu sync.Mutex } // 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 pw.Close() + fmt.Printf("[DEBUG demux] Starting stdcopy.StdCopy\n") + startTime := time.Now() + // Use stdcopy to demux stdout and stderr // 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 { + d.mu.Lock() d.err = err + d.mu.Unlock() } }() @@ -76,15 +87,39 @@ func (d *demuxReadCloser) Read(p []byte) (int, 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 { d.closer.Close() } + // Close the pipe reader to unblock any pending reads d.pipeReader.Close() - // Wait for demux goroutine to finish - <-d.done - return d.err + + // Wait for demux goroutine to finish with a timeout + // 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 @@ -562,6 +597,39 @@ func (m *Manager) Remove(ctx context.Context, name string) error { 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 func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error) { var result []*Container diff --git a/sandbox/proxy/README.md b/sandbox/proxy/README.md new file mode 100644 index 00000000..7d779629 --- /dev/null +++ b/sandbox/proxy/README.md @@ -0,0 +1,214 @@ +# Claude API Proxy + +A lightweight API proxy that allows Claude CLI to use any OpenAI-compatible backend API (Volcengine, DeepSeek, GLM, etc.). + +## Features + +- **Zero dependencies**: Uses only Go standard library +- **Lightweight**: Single executable binary +- **True streaming**: Direct SSE forwarding, no buffering +- **Full tool calling support**: Both streaming and non-streaming +- **Image content support**: Base64 and URL formats +- **Multi-architecture**: Supports amd64 and arm64 + +## Architecture + +``` +Claude CLI (Anthropic Messages API) + │ + ▼ +claude-proxy (localhost:3456) + │ + │ Convert: Anthropic → OpenAI + ▼ +OpenAI-compatible Backend (Volcengine/DeepSeek/GLM...) + │ + │ Convert: OpenAI → Anthropic + ▼ +Claude CLI (Real-time streaming output) +``` + +## Command Line Options + +```bash +claude-proxy [options] + +Options: + -p, --port Listen port (default: 3456) + -b, --backend Backend API URL (required) + -m, --model Backend model name (required) + -k, --api-key Backend API key (required) + -l, --log Log file path + -t, --timeout Request timeout (default: 300) + -v, --verbose Verbose logging + -h, --help Show help + +Environment Variables: + CLAUDE_PROXY_PORT Listen port + CLAUDE_PROXY_BACKEND Backend API URL + CLAUDE_PROXY_MODEL Backend model name + CLAUDE_PROXY_API_KEY Backend API key + CLAUDE_PROXY_TIMEOUT Timeout in seconds +``` + +## Usage + +### Method 1: Command Line Arguments + +```bash +# Using Volcengine GLM-4 +claude-proxy -b https://ark.cn-beijing.volces.com/api/v3/chat/completions \ + -m glm-4-7-251222 \ + -k your-api-key \ + -v + +# Using DeepSeek +claude-proxy -b https://api.deepseek.com/chat/completions \ + -m deepseek-chat \ + -k your-api-key +``` + +### Method 2: Environment Variables + +```bash +export CLAUDE_PROXY_BACKEND="https://ark.cn-beijing.volces.com/api/v3/chat/completions" +export CLAUDE_PROXY_API_KEY="your-api-key" +export CLAUDE_PROXY_MODEL="glm-4-7-251222" +claude-proxy -v +``` + +### Method 3: Config File (Inside Container) + +Create config file at `/workspace/.claude-proxy.json`: + +```json +{ + "backend": "https://ark.cn-beijing.volces.com/api/v3/chat/completions", + "api_key": "your-api-key", + "model": "glm-4-7-251222" +} +``` + +Then run: + +```bash +start-claude-proxy +``` + +## Container Usage + +### Docker Run (via Environment Variables) + +```bash +docker run -d \ + -e CLAUDE_PROXY_BACKEND="https://ark.cn-beijing.volces.com/api/v3/chat/completions" \ + -e CLAUDE_PROXY_API_KEY="your-api-key" \ + -e CLAUDE_PROXY_MODEL="your-model-name" \ + yaoapp/sandbox-claude:latest +``` + +The container will automatically start claude-proxy on startup. + +### Using claude-run Wrapper + +```bash +# Enter the container +docker exec -it bash + +# Set environment variables +export CLAUDE_PROXY_BACKEND="https://ark.cn-beijing.volces.com/api/v3/chat/completions" +export CLAUDE_PROXY_API_KEY="your-api-key" +export CLAUDE_PROXY_MODEL="your-model-name" + +# Use claude-run wrapper (auto-starts proxy) +claude-run --dangerously-skip-permissions "Build me a website" +``` + +### Direct Claude CLI Usage + +```bash +# Ensure proxy is running +curl http://127.0.0.1:3456/health + +# Set Claude CLI environment +export ANTHROPIC_BASE_URL=http://127.0.0.1:3456 +export ANTHROPIC_API_KEY=dummy + +# Use Claude CLI +claude -p --dangerously-skip-permissions --permission-mode bypassPermissions "Build me a website" +``` + +## Claude CLI Common Options + +```bash +# Basic usage (max permissions, no questions) +claude -p --dangerously-skip-permissions --permission-mode bypassPermissions "your task" + +# Streaming JSON output +claude -p --dangerously-skip-permissions --output-format stream-json --verbose "your task" + +# Interactive mode (real-time streaming output) +claude --dangerously-skip-permissions "your task" +``` + +### Option Reference + +| Option | Description | +| ------------------------------------- | ----------------------------------------- | +| `-p, --print` | Print mode, exit after output | +| `--dangerously-skip-permissions` | Skip all permission checks | +| `--permission-mode bypassPermissions` | Bypass permission mode | +| `--output-format stream-json` | Output JSON stream | +| `--verbose` | Verbose output (required for stream-json) | + +## Viewing Logs + +```bash +# View proxy logs inside container +tail -f /workspace/proxy.log + +# Check health status +curl http://127.0.0.1:3456/health +``` + +## Supported Backends + +| Backend | API URL | +| ---------------------------- | ----------------------------------------------------------- | +| Volcengine GLM | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` | +| Volcengine DeepSeek | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` | +| DeepSeek Official | `https://api.deepseek.com/chat/completions` | +| OpenAI | `https://api.openai.com/v1/chat/completions` | +| Other OpenAI-compatible APIs | Custom URL | + +## API Endpoints + +### POST /v1/messages + +Main endpoint, accepts Anthropic Messages API format requests. + +### GET /health + +Health check endpoint, returns `{"status": "ok"}`. + +## Building + +```bash +# Local build +go build -o claude-proxy ./cmd/claude-proxy/ + +# Cross-compile +GOOS=linux GOARCH=amd64 go build -o claude-proxy-amd64 ./cmd/claude-proxy/ +GOOS=linux GOARCH=arm64 go build -o claude-proxy-arm64 ./cmd/claude-proxy/ +``` + +## Yao Integration + +Yao's sandbox executor automatically: + +1. Writes connector config to `/workspace/.claude-proxy.json` when creating container +2. Calls `start-claude-proxy` to start the proxy +3. Sets `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` environment variables +4. Executes Claude CLI commands + +No manual configuration required. diff --git a/sandbox/proxy/cmd/claude-proxy/main.go b/sandbox/proxy/cmd/claude-proxy/main.go new file mode 100644 index 00000000..4c9aa32a --- /dev/null +++ b/sandbox/proxy/cmd/claude-proxy/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/yaoapp/yao/sandbox/proxy" + +func main() { + proxy.Main() +} diff --git a/sandbox/proxy/convert.go b/sandbox/proxy/convert.go new file mode 100644 index 00000000..02879b43 --- /dev/null +++ b/sandbox/proxy/convert.go @@ -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 + } +} diff --git a/sandbox/proxy/main.go b/sandbox/proxy/main.go new file mode 100644 index 00000000..903ca969 --- /dev/null +++ b/sandbox/proxy/main.go @@ -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" + } +} diff --git a/sandbox/proxy/types.go b/sandbox/proxy/types.go new file mode 100644 index 00000000..4a4f9e88 --- /dev/null +++ b/sandbox/proxy/types.go @@ -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 +}