Merge pull request #1444 from trheyi/main
Refactor Claude sandbox: replace CCR with claude-proxy and fix streaming issues
This commit is contained in:
commit
7857b356d9
23 changed files with 4107 additions and 350 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -57,3 +57,6 @@ agent/test/UPGRADE_PLAN.md
|
||||||
introduction/*
|
introduction/*
|
||||||
!sandbox/docker/build.sh
|
!sandbox/docker/build.sh
|
||||||
sandbox/docker/yao-bridge-*
|
sandbox/docker/yao-bridge-*
|
||||||
|
sandbox/docker/claude-proxy-*
|
||||||
|
sandbox/docker/claude/claude-proxy-*
|
||||||
|
sandbox/proxy/claude-proxy-linux-*
|
||||||
|
|
|
||||||
|
|
@ -157,10 +157,11 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
// Sandbox must be created BEFORE hooks so that hooks can access ctx.sandbox
|
// Sandbox must be created BEFORE hooks so that hooks can access ctx.sandbox
|
||||||
var sandboxExecutor agentsandbox.Executor
|
var sandboxExecutor agentsandbox.Executor
|
||||||
var sandboxCleanup func()
|
var sandboxCleanup func()
|
||||||
|
var sandboxLoadingMsgID string
|
||||||
if ast.HasSandbox() {
|
if ast.HasSandbox() {
|
||||||
ctx.Logger.Phase("Sandbox")
|
ctx.Logger.Phase("Sandbox")
|
||||||
var err error
|
var err error
|
||||||
sandboxExecutor, sandboxCleanup, err = ast.initSandbox(ctx, opts)
|
sandboxExecutor, sandboxCleanup, sandboxLoadingMsgID, err = ast.initSandbox(ctx, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ast.traceAgentFail(agentNode, err)
|
ast.traceAgentFail(agentNode, err)
|
||||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||||
|
|
@ -285,7 +286,7 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
// Choose between sandbox execution or direct LLM execution
|
// Choose between sandbox execution or direct LLM execution
|
||||||
if ast.HasSandbox() {
|
if ast.HasSandbox() {
|
||||||
// Sandbox execution path (Claude CLI, Cursor CLI, etc.)
|
// Sandbox execution path (Claude CLI, Cursor CLI, etc.)
|
||||||
completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor)
|
completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor, sandboxLoadingMsgID)
|
||||||
} else {
|
} else {
|
||||||
// Direct LLM execution path
|
// Direct LLM execution path
|
||||||
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
|
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
gouMCP "github.com/yaoapp/gou/mcp"
|
gouMCP "github.com/yaoapp/gou/mcp"
|
||||||
mcpProcess "github.com/yaoapp/gou/mcp/process"
|
mcpProcess "github.com/yaoapp/gou/mcp/process"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
|
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
|
|
@ -55,22 +56,22 @@ func (ast *Assistant) HasSandbox() bool {
|
||||||
// Returns the full Executor (for LLM calls), cleanup function, and any error
|
// Returns the full Executor (for LLM calls), cleanup function, and any error
|
||||||
// This is called BEFORE hooks so that hooks can access ctx.sandbox
|
// This is called BEFORE hooks so that hooks can access ctx.sandbox
|
||||||
// The executor implements both agentsandbox.Executor and context.SandboxExecutor interfaces
|
// The executor implements both agentsandbox.Executor and context.SandboxExecutor interfaces
|
||||||
func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (agentsandbox.Executor, func(), error) {
|
func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (agentsandbox.Executor, func(), string, error) {
|
||||||
// Get sandbox manager (singleton)
|
// Get sandbox manager (singleton)
|
||||||
manager, err := GetSandboxManager()
|
manager, err := GetSandboxManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Error("Sandbox manager initialization failed: %v", err)
|
ctx.Logger.Error("Sandbox manager initialization failed: %v", err)
|
||||||
return nil, nil, fmt.Errorf("sandbox manager not available: %w", err)
|
return nil, nil, "", fmt.Errorf("sandbox manager not available: %w", err)
|
||||||
}
|
}
|
||||||
if manager == nil {
|
if manager == nil {
|
||||||
return nil, nil, fmt.Errorf("sandbox manager not initialized")
|
return nil, nil, "", fmt.Errorf("sandbox manager not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build executor options from assistant config
|
// Build executor options from assistant config
|
||||||
execOpts, err := ast.buildSandboxOptions(ctx, opts)
|
execOpts, err := ast.buildSandboxOptions(ctx, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Error("Failed to build sandbox options: %v", err)
|
ctx.Logger.Error("Failed to build sandbox options: %v", err)
|
||||||
return nil, nil, fmt.Errorf("failed to build sandbox options: %w", err)
|
return nil, nil, "", fmt.Errorf("failed to build sandbox options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log sandbox creation
|
// Log sandbox creation
|
||||||
|
|
@ -86,7 +87,7 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (
|
||||||
loadingMsg := &message.Message{
|
loadingMsg := &message.Message{
|
||||||
Type: message.TypeLoading,
|
Type: message.TypeLoading,
|
||||||
Props: map[string]interface{}{
|
Props: map[string]interface{}{
|
||||||
"message": "Preparing sandbox environment...",
|
"message": i18n.T(ctx.Locale, "sandbox.preparing"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
loadingMsgID, _ := ctx.SendStream(loadingMsg)
|
loadingMsgID, _ := ctx.SendStream(loadingMsg)
|
||||||
|
|
@ -98,11 +99,21 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (
|
||||||
if traceErr == nil && trace != nil {
|
if traceErr == nil && trace != nil {
|
||||||
trace.Error("Sandbox creation failed: %v", err)
|
trace.Error("Sandbox creation failed: %v", err)
|
||||||
}
|
}
|
||||||
// End loading message
|
// End loading message with done:true
|
||||||
if loadingMsgID != "" {
|
if loadingMsgID != "" {
|
||||||
ctx.End(loadingMsgID)
|
doneMsg := &message.Message{
|
||||||
|
MessageID: loadingMsgID,
|
||||||
|
Delta: true,
|
||||||
|
DeltaAction: message.DeltaReplace,
|
||||||
|
Type: message.TypeLoading,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"message": i18n.T(ctx.Locale, "sandbox.failed"),
|
||||||
|
"done": true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return nil, nil, fmt.Errorf("failed to create sandbox executor: %w", err)
|
ctx.Send(doneMsg)
|
||||||
|
}
|
||||||
|
return nil, nil, "", fmt.Errorf("failed to create sandbox executor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log sandbox ready
|
// Log sandbox ready
|
||||||
|
|
@ -111,11 +122,6 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (
|
||||||
trace.Info("Sandbox container ready")
|
trace.Info("Sandbox container ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
// End loading message
|
|
||||||
if loadingMsgID != "" {
|
|
||||||
ctx.End(loadingMsgID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return cleanup function
|
// Return cleanup function
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
if err := executor.Close(); err != nil {
|
if err := executor.Close(); err != nil {
|
||||||
|
|
@ -123,7 +129,9 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return executor, cleanup, nil
|
// Keep loadingMsgID open - it will be closed when first output is received
|
||||||
|
// This provides better UX: user sees "Preparing..." until actual content appears
|
||||||
|
return executor, cleanup, loadingMsgID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeSandboxStream executes the request using sandbox (Claude CLI, etc.)
|
// executeSandboxStream executes the request using sandbox (Claude CLI, etc.)
|
||||||
|
|
@ -135,6 +143,7 @@ func (ast *Assistant) executeSandboxStream(
|
||||||
agentNode traceTypes.Node,
|
agentNode traceTypes.Node,
|
||||||
streamHandler message.StreamFunc,
|
streamHandler message.StreamFunc,
|
||||||
executor agentsandbox.Executor,
|
executor agentsandbox.Executor,
|
||||||
|
loadingMsgID string,
|
||||||
) (*context.CompletionResponse, error) {
|
) (*context.CompletionResponse, error) {
|
||||||
|
|
||||||
// Mark the agentNode as used to avoid unused variable error
|
// Mark the agentNode as used to avoid unused variable error
|
||||||
|
|
@ -147,9 +156,41 @@ func (ast *Assistant) executeSandboxStream(
|
||||||
// Log sandbox execution
|
// Log sandbox execution
|
||||||
ctx.Logger.Info("Executing via sandbox (command: %s)", ast.Sandbox.Command)
|
ctx.Logger.Info("Executing via sandbox (command: %s)", ast.Sandbox.Command)
|
||||||
|
|
||||||
|
// Pass the "preparing sandbox" loading message ID to executor
|
||||||
|
// It will be closed when first output (text or tool) is received
|
||||||
|
if loadingMsgID != "" {
|
||||||
|
executor.SetLoadingMsgID(loadingMsgID)
|
||||||
|
}
|
||||||
|
|
||||||
// Execute LLM call via sandbox
|
// Execute LLM call via sandbox
|
||||||
|
// The loadingMsgID will be closed when first output is received
|
||||||
|
// Tool calls will create their own loading messages below the text
|
||||||
resp, err := executor.Stream(ctx, completionMessages, streamHandler)
|
resp, err := executor.Stream(ctx, completionMessages, streamHandler)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Close loading message on error
|
||||||
|
if loadingMsgID != "" {
|
||||||
|
doneMsg := &message.Message{
|
||||||
|
MessageID: loadingMsgID,
|
||||||
|
Delta: true,
|
||||||
|
DeltaAction: message.DeltaReplace,
|
||||||
|
Type: message.TypeLoading,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"message": i18n.T(ctx.Locale, "sandbox.failed"),
|
||||||
|
"done": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx.Send(doneMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send error message to client
|
||||||
|
errMsg := &message.Message{
|
||||||
|
Type: message.TypeError,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx.Send(errMsg)
|
||||||
return nil, fmt.Errorf("sandbox execution failed: %w", err)
|
return nil, fmt.Errorf("sandbox execution failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,6 +239,18 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if assistant has prompts (from prompts.yml)
|
||||||
|
// If prompts are configured, we need to call Claude CLI
|
||||||
|
if len(ast.Prompts) > 0 {
|
||||||
|
// Extract system prompt from prompts
|
||||||
|
for _, prompt := range ast.Prompts {
|
||||||
|
if prompt.Role == "system" && prompt.Content != "" {
|
||||||
|
execOpts.SystemPrompt = prompt.Content
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve connector settings
|
// Resolve connector settings
|
||||||
conn, _, err := ast.GetConnector(ctx, opts)
|
conn, _, err := ast.GetConnector(ctx, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -215,6 +268,40 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op
|
||||||
execOpts.Model = model
|
execOpts.Model = model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract extra connector options (thinking, max_tokens, temperature, etc.)
|
||||||
|
// These are backend-specific parameters that need to be passed through to the proxy
|
||||||
|
connectorOptions := make(map[string]interface{})
|
||||||
|
for k, v := range setting {
|
||||||
|
// Skip standard fields that are already handled
|
||||||
|
switch k {
|
||||||
|
case "host", "key", "model", "azure", "capabilities":
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
// Include all other fields as extra options
|
||||||
|
connectorOptions[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(connectorOptions) > 0 {
|
||||||
|
execOpts.ConnectorOptions = connectorOptions
|
||||||
|
ctx.Logger.Debug("Connector options extracted: %v", connectorOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract secrets from sandbox config (e.g., GITHUB_TOKEN: "$ENV.GITHUB_TOKEN")
|
||||||
|
if ast.Sandbox != nil && len(ast.Sandbox.Secrets) > 0 {
|
||||||
|
secrets := make(map[string]string)
|
||||||
|
for k, v := range ast.Sandbox.Secrets {
|
||||||
|
// Resolve $ENV.XXX references
|
||||||
|
resolved := resolveEnvValue(v)
|
||||||
|
if resolved != "" {
|
||||||
|
secrets[k] = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(secrets) > 0 {
|
||||||
|
execOpts.Secrets = secrets
|
||||||
|
ctx.Logger.Debug("Secrets extracted: %d items", len(secrets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build MCP config and load tools if the assistant has MCP servers configured
|
// Build MCP config and load tools if the assistant has MCP servers configured
|
||||||
if ast.MCP != nil && len(ast.MCP.Servers) > 0 {
|
if ast.MCP != nil && len(ast.MCP.Servers) > 0 {
|
||||||
// Build MCP config for Claude CLI
|
// Build MCP config for Claude CLI
|
||||||
|
|
@ -354,3 +441,21 @@ func (ast *Assistant) BuildMCPConfigForSandbox(ctx *context.Context) ([]byte, er
|
||||||
|
|
||||||
return json.Marshal(config)
|
return json.Marshal(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveEnvValue resolves environment variable references in a string
|
||||||
|
// Supports format: $ENV.VAR_NAME or plain value
|
||||||
|
// Returns empty string if the variable is not set
|
||||||
|
func resolveEnvValue(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for $ENV.XXX format
|
||||||
|
if len(value) > 5 && value[:5] == "$ENV." {
|
||||||
|
envName := value[5:]
|
||||||
|
return os.Getenv(envName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return as-is if not an env reference
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,28 +78,35 @@ func TestClaudeCommandBuilding(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify command structure
|
// Verify command structure
|
||||||
// Command is now: ["bash", "-c", "nohup ccr start ... && ccr code ..."]
|
// Command is now: ["bash", "-c", "cat << 'INPUTEOF' | claude -p ... INPUTEOF"]
|
||||||
assert.NotEmpty(t, cmd)
|
assert.NotEmpty(t, cmd)
|
||||||
assert.Equal(t, "bash", cmd[0], "Command should start with bash")
|
assert.Equal(t, "bash", cmd[0], "Command should start with bash")
|
||||||
assert.Equal(t, "-c", cmd[1], "Second arg should be -c")
|
assert.Equal(t, "-c", cmd[1], "Second arg should be -c")
|
||||||
assert.Contains(t, cmd[2], "ccr code", "Bash command should contain ccr code")
|
assert.Contains(t, cmd[2], "claude -p", "Bash command should contain claude -p")
|
||||||
assert.Contains(t, cmd[2], "--permission-mode", "Should include permission mode")
|
assert.Contains(t, cmd[2], "--permission-mode", "Should include permission mode")
|
||||||
|
assert.Contains(t, cmd[2], "--input-format", "Should include input-format flag")
|
||||||
|
assert.Contains(t, cmd[2], "--output-format", "Should include output-format flag")
|
||||||
|
assert.Contains(t, cmd[2], "--verbose", "Should include verbose flag")
|
||||||
|
assert.Contains(t, cmd[2], "stream-json", "Should use stream-json format")
|
||||||
|
assert.Contains(t, cmd[2], "INPUTEOF", "Should use heredoc for input")
|
||||||
t.Logf("Built command: %v", cmd)
|
t.Logf("Built command: %v", cmd)
|
||||||
|
|
||||||
// Verify environment variables
|
// Verify environment variables (claude-proxy mode)
|
||||||
assert.NotEmpty(t, env)
|
assert.NotEmpty(t, env)
|
||||||
assert.Equal(t, "https://ark.cn-beijing.volces.com/api/v3", env["CCR_API_BASE"])
|
assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"], "Should set proxy base URL")
|
||||||
assert.Equal(t, "test-api-key", env["CCR_API_KEY"])
|
assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"], "Should set dummy API key for proxy")
|
||||||
assert.Equal(t, "ep-xxxxx", env["CCR_MODEL"])
|
|
||||||
assert.Equal(t, "10", env["CLAUDE_MAX_TURNS"])
|
// max_turns is passed via CLI flag
|
||||||
assert.Equal(t, "acceptEdits", env["CLAUDE_PERMISSION_MODE"])
|
// system prompt is written to file via heredoc, then referenced via --append-system-prompt-file
|
||||||
assert.Equal(t, "stream-json", env["CLAUDE_OUTPUT_FORMAT"])
|
assert.Contains(t, cmd[2], "--max-turns", "Should include max-turns flag")
|
||||||
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a helpful coding assistant")
|
assert.Contains(t, cmd[2], "cat << 'PROMPTEOF' > /tmp/.system-prompt.txt", "Should use heredoc for system prompt")
|
||||||
|
assert.Contains(t, cmd[2], "--append-system-prompt-file", "Should include append-system-prompt-file flag")
|
||||||
|
assert.Contains(t, cmd[2], "You are a helpful coding assistant", "Command should contain system prompt")
|
||||||
t.Logf("Built environment: %v", env)
|
t.Logf("Built environment: %v", env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClaudeCCRConfigBuilding tests that CCR config is correctly built
|
// TestClaudeProxyConfigBuilding tests that claude-proxy config is correctly built
|
||||||
func TestClaudeCCRConfigBuilding(t *testing.T) {
|
func TestClaudeProxyConfigBuilding(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
|
|
@ -109,20 +116,20 @@ func TestClaudeCCRConfigBuilding(t *testing.T) {
|
||||||
Model: "ep-xxxxx",
|
Model: "ep-xxxxx",
|
||||||
}
|
}
|
||||||
|
|
||||||
configJSON, err := claude.BuildCCRConfig(opts)
|
configJSON, err := claude.BuildProxyConfig(opts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, configJSON)
|
require.NotEmpty(t, configJSON)
|
||||||
|
|
||||||
t.Logf("CCR config: %s", string(configJSON))
|
t.Logf("Proxy config: %s", string(configJSON))
|
||||||
|
|
||||||
// Verify the JSON contains expected fields (CCR uses snake_case)
|
// Verify the JSON contains expected fields for claude-proxy
|
||||||
assert.Contains(t, string(configJSON), "api_base_url")
|
assert.Contains(t, string(configJSON), "backend")
|
||||||
assert.Contains(t, string(configJSON), "api_key")
|
assert.Contains(t, string(configJSON), "api_key")
|
||||||
assert.Contains(t, string(configJSON), "models")
|
assert.Contains(t, string(configJSON), "model")
|
||||||
// Verify CCR format fields
|
assert.Contains(t, string(configJSON), "test-api-key")
|
||||||
assert.Contains(t, string(configJSON), "Providers")
|
assert.Contains(t, string(configJSON), "ep-xxxxx")
|
||||||
assert.Contains(t, string(configJSON), "Router")
|
// Backend URL should end with /chat/completions
|
||||||
assert.Contains(t, string(configJSON), "volcengine")
|
assert.Contains(t, string(configJSON), "/chat/completions")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultImageSelection tests that default images are correctly selected
|
// TestDefaultImageSelection tests that default images are correctly selected
|
||||||
|
|
|
||||||
335
agent/caller/sandbox_integration_test.go
Normal file
335
agent/caller/sandbox_integration_test.go
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
package caller_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
"github.com/yaoapp/yao/agent/caller"
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSandboxE2E_ClaudeCLIExecution tests the full sandbox + claude-proxy integration
|
||||||
|
// This test verifies:
|
||||||
|
// 1. Assistant loads with sandbox and prompts configured
|
||||||
|
// 2. Claude CLI is invoked (not skipped) because prompts exist
|
||||||
|
// 3. claude-proxy correctly translates requests to OpenAI backend
|
||||||
|
// 4. Response is received with actual content
|
||||||
|
func TestSandboxE2E_ClaudeCLIExecution(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping sandbox E2E test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the e2e-test assistant
|
||||||
|
ast, err := assistant.Get("tests.sandbox.e2e-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: e2e-test assistant not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify configuration
|
||||||
|
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||||
|
require.NotEmpty(t, ast.Prompts, "Prompts should be configured (required for Claude CLI)")
|
||||||
|
t.Logf("✓ Assistant loaded: sandbox=%s, prompts=%d", ast.Sandbox.Command, len(ast.Prompts))
|
||||||
|
|
||||||
|
// Create authorized info
|
||||||
|
authorized := &types.AuthorizedInfo{
|
||||||
|
Subject: "sandbox-e2e-test",
|
||||||
|
UserID: "e2e-user-123",
|
||||||
|
TenantID: "e2e-tenant",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context with unique chat ID
|
||||||
|
chatID := "sandbox-e2e-" + time.Now().Format("20060102-150405")
|
||||||
|
ctx := agentContext.New(context.Background(), authorized, chatID)
|
||||||
|
ctx.AssistantID = "tests.sandbox.e2e-test"
|
||||||
|
|
||||||
|
// Create JSAPI
|
||||||
|
api := caller.NewJSAPI(ctx)
|
||||||
|
|
||||||
|
// Test 1: Simple echo command
|
||||||
|
t.Run("EchoCommand", func(t *testing.T) {
|
||||||
|
messages := []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Run this command: echo 'SANDBOX_E2E_SUCCESS_12345'",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := map[string]interface{}{
|
||||||
|
"skip": map[string]interface{}{
|
||||||
|
"history": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
result := api.Call("tests.sandbox.e2e-test", messages, opts)
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
t.Logf("Execution time: %v", duration)
|
||||||
|
|
||||||
|
require.NotNil(t, result, "Result should not be nil")
|
||||||
|
|
||||||
|
r, ok := result.(*caller.Result)
|
||||||
|
require.True(t, ok, "Result should be *caller.Result")
|
||||||
|
|
||||||
|
// Check for errors
|
||||||
|
if r.Error != "" {
|
||||||
|
// Check if it's a Docker/sandbox availability issue
|
||||||
|
if strings.Contains(r.Error, "Docker") ||
|
||||||
|
strings.Contains(r.Error, "sandbox") ||
|
||||||
|
strings.Contains(r.Error, "container") {
|
||||||
|
t.Skipf("Skipping: Docker/sandbox not available: %s", r.Error)
|
||||||
|
}
|
||||||
|
t.Fatalf("Agent call failed: %s", r.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify response
|
||||||
|
t.Logf("Response content: %s", truncateStr(r.Content, 500))
|
||||||
|
assert.NotEmpty(t, r.Content, "Response content should not be empty")
|
||||||
|
|
||||||
|
// Check if Claude executed the command
|
||||||
|
if strings.Contains(r.Content, "SANDBOX_E2E_SUCCESS_12345") {
|
||||||
|
t.Log("✓ Echo command executed successfully - found verification string")
|
||||||
|
} else if strings.Contains(strings.ToLower(r.Content), "echo") ||
|
||||||
|
strings.Contains(r.Content, "SANDBOX") {
|
||||||
|
t.Log("✓ Response mentions the command or partial output")
|
||||||
|
} else {
|
||||||
|
t.Log("⚠ Response does not contain expected output")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSandboxE2E_FileCreation tests that Claude can create files in the sandbox
|
||||||
|
func TestSandboxE2E_FileCreation(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping sandbox E2E test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the e2e-test assistant
|
||||||
|
ast, err := assistant.Get("tests.sandbox.e2e-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: e2e-test assistant not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, ast.Sandbox)
|
||||||
|
require.NotEmpty(t, ast.Prompts)
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
authorized := &types.AuthorizedInfo{
|
||||||
|
Subject: "sandbox-e2e-test",
|
||||||
|
UserID: "e2e-user-456",
|
||||||
|
TenantID: "e2e-tenant",
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID := "sandbox-file-" + time.Now().Format("20060102-150405")
|
||||||
|
ctx := agentContext.New(context.Background(), authorized, chatID)
|
||||||
|
ctx.AssistantID = "tests.sandbox.e2e-test"
|
||||||
|
|
||||||
|
api := caller.NewJSAPI(ctx)
|
||||||
|
|
||||||
|
messages := []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Create a file named 'test-output.txt' with the content 'FILE_CREATION_VERIFIED_67890', then read it back and show me the content.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := map[string]interface{}{
|
||||||
|
"skip": map[string]interface{}{
|
||||||
|
"history": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
result := api.Call("tests.sandbox.e2e-test", messages, opts)
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
t.Logf("Execution time: %v", duration)
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
r, ok := result.(*caller.Result)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
if r.Error != "" {
|
||||||
|
if strings.Contains(r.Error, "Docker") ||
|
||||||
|
strings.Contains(r.Error, "sandbox") {
|
||||||
|
t.Skipf("Skipping: Docker/sandbox not available: %s", r.Error)
|
||||||
|
}
|
||||||
|
t.Fatalf("Agent call failed: %s", r.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Response: %s", truncateStr(r.Content, 800))
|
||||||
|
|
||||||
|
// Verify file was created and read back
|
||||||
|
if strings.Contains(r.Content, "FILE_CREATION_VERIFIED_67890") {
|
||||||
|
t.Log("✓ File creation and read verified")
|
||||||
|
} else if strings.Contains(strings.ToLower(r.Content), "created") ||
|
||||||
|
strings.Contains(strings.ToLower(r.Content), "wrote") ||
|
||||||
|
strings.Contains(r.Content, "test-output.txt") {
|
||||||
|
t.Log("✓ File operation appears successful")
|
||||||
|
} else {
|
||||||
|
t.Log("⚠ Could not verify file creation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSandboxE2E_HookOnlyMode tests that hooks can work without Claude CLI
|
||||||
|
func TestSandboxE2E_HookOnlyMode(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping sandbox E2E test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the hook-only assistant (no prompts)
|
||||||
|
ast, err := assistant.Get("tests.sandbox.hook-only")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: hook-only assistant not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify configuration - no prompts means Claude CLI should be skipped
|
||||||
|
require.NotNil(t, ast.Sandbox)
|
||||||
|
require.Empty(t, ast.Prompts, "Hook-only mode should have no prompts")
|
||||||
|
t.Logf("✓ Hook-only assistant loaded: sandbox=%s, prompts=%d (should be 0)", ast.Sandbox.Command, len(ast.Prompts))
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
authorized := &types.AuthorizedInfo{
|
||||||
|
Subject: "sandbox-hook-test",
|
||||||
|
UserID: "hook-user-789",
|
||||||
|
TenantID: "hook-tenant",
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID := "sandbox-hook-" + time.Now().Format("20060102-150405")
|
||||||
|
ctx := agentContext.New(context.Background(), authorized, chatID)
|
||||||
|
ctx.AssistantID = "tests.sandbox.hook-only"
|
||||||
|
|
||||||
|
api := caller.NewJSAPI(ctx)
|
||||||
|
|
||||||
|
messages := []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": "test hook-only mode",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := map[string]interface{}{
|
||||||
|
"skip": map[string]interface{}{
|
||||||
|
"history": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
result := api.Call("tests.sandbox.hook-only", messages, opts)
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
t.Logf("Execution time: %v", duration)
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
r, ok := result.(*caller.Result)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
if r.Error != "" {
|
||||||
|
if strings.Contains(r.Error, "Docker") ||
|
||||||
|
strings.Contains(r.Error, "sandbox") {
|
||||||
|
t.Skipf("Skipping: Docker/sandbox not available: %s", r.Error)
|
||||||
|
}
|
||||||
|
t.Fatalf("Agent call failed: %s", r.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Response: %s", r.Content)
|
||||||
|
t.Log("✓ Hook-only mode executed successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSandboxE2E_StreamingResponse verifies streaming works correctly
|
||||||
|
func TestSandboxE2E_StreamingResponse(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping sandbox E2E test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
testutils.Prepare(t)
|
||||||
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
// Load the e2e-test assistant
|
||||||
|
ast, err := assistant.Get("tests.sandbox.e2e-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: e2e-test assistant not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, ast.Sandbox)
|
||||||
|
require.NotEmpty(t, ast.Prompts)
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
authorized := &types.AuthorizedInfo{
|
||||||
|
Subject: "sandbox-stream-test",
|
||||||
|
UserID: "stream-user",
|
||||||
|
TenantID: "stream-tenant",
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID := "sandbox-stream-" + time.Now().Format("20060102-150405")
|
||||||
|
ctx := agentContext.New(context.Background(), authorized, chatID)
|
||||||
|
ctx.AssistantID = "tests.sandbox.e2e-test"
|
||||||
|
|
||||||
|
api := caller.NewJSAPI(ctx)
|
||||||
|
|
||||||
|
// Ask for a slightly longer response to verify streaming
|
||||||
|
messages := []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Say 'Hello World' and nothing else.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := map[string]interface{}{
|
||||||
|
"skip": map[string]interface{}{
|
||||||
|
"history": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
result := api.Call("tests.sandbox.e2e-test", messages, opts)
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
t.Logf("Execution time: %v", duration)
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
|
||||||
|
r, ok := result.(*caller.Result)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
if r.Error != "" {
|
||||||
|
if strings.Contains(r.Error, "Docker") ||
|
||||||
|
strings.Contains(r.Error, "sandbox") {
|
||||||
|
t.Skipf("Skipping: Docker/sandbox not available: %s", r.Error)
|
||||||
|
}
|
||||||
|
t.Fatalf("Agent call failed: %s", r.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Response: %s", r.Content)
|
||||||
|
|
||||||
|
// Verify we got a response
|
||||||
|
assert.NotEmpty(t, r.Content, "Should have response content")
|
||||||
|
|
||||||
|
if strings.Contains(strings.ToLower(r.Content), "hello") {
|
||||||
|
t.Log("✓ Streaming response received with expected content")
|
||||||
|
} else {
|
||||||
|
t.Log("✓ Streaming response received")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateStr(s string, maxLen int) string {
|
||||||
|
s = strings.ReplaceAll(s, "\n", " ")
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
|
@ -99,14 +99,39 @@ func init() {
|
||||||
"kb.chat.name": "Chat Knowledge Base",
|
"kb.chat.name": "Chat Knowledge Base",
|
||||||
"kb.chat.description": "Auto-created knowledge base collection for chat sessions",
|
"kb.chat.description": "Auto-created knowledge base collection for chat sessions",
|
||||||
|
|
||||||
|
// Sandbox: assistant/sandbox.go - Sandbox status messages
|
||||||
|
"sandbox.preparing": "Preparing sandbox environment",
|
||||||
|
"sandbox.ready": "Sandbox ready",
|
||||||
|
"sandbox.working": "Working on your request",
|
||||||
|
"sandbox.completed": "Completed",
|
||||||
|
"sandbox.failed": "Execution failed",
|
||||||
|
|
||||||
|
// Sandbox: claude/executor.go - Tool execution messages
|
||||||
|
"sandbox.tool.read": "Reading file",
|
||||||
|
"sandbox.tool.write": "Writing file",
|
||||||
|
"sandbox.tool.edit": "Editing file",
|
||||||
|
"sandbox.tool.bash": "Running command",
|
||||||
|
"sandbox.tool.glob": "Finding files",
|
||||||
|
"sandbox.tool.grep": "Searching code",
|
||||||
|
"sandbox.tool.ls": "Listing directory",
|
||||||
|
"sandbox.tool.task": "Running subtask",
|
||||||
|
"sandbox.tool.web_search": "Searching web",
|
||||||
|
"sandbox.tool.web_fetch": "Fetching URL",
|
||||||
|
"sandbox.tool.todo_write": "Managing tasks",
|
||||||
|
"sandbox.tool.ask_question": "Asking question",
|
||||||
|
"sandbox.tool.switch_mode": "Switching mode",
|
||||||
|
"sandbox.tool.read_lints": "Checking lints",
|
||||||
|
"sandbox.tool.edit_notebook": "Editing notebook",
|
||||||
|
"sandbox.tool.unknown": "Executing {{name}}",
|
||||||
|
|
||||||
// Content: content/image/image.go - Image processing messages
|
// Content: content/image/image.go - Image processing messages
|
||||||
"content.image.analyzing": "Analyzing image...",
|
"content.image.analyzing": "Analyzing image",
|
||||||
|
|
||||||
// Content: content/pdf/pdf.go - PDF processing messages
|
// Content: content/pdf/pdf.go - PDF processing messages
|
||||||
"content.pdf.analyzing_page": "Analyzing PDF page %d/%d...",
|
"content.pdf.analyzing_page": "Analyzing PDF page %d/%d",
|
||||||
|
|
||||||
// Search: assistant/search.go - Output messages
|
// Search: assistant/search.go - Output messages
|
||||||
"search.loading": "Searching...",
|
"search.loading": "Searching",
|
||||||
"search.success": "Found %d references",
|
"search.success": "Found %d references",
|
||||||
"search.success.one": "Found 1 reference",
|
"search.success.one": "Found 1 reference",
|
||||||
"search.partial": "Found %d references (some sources failed)",
|
"search.partial": "Found %d references (some sources failed)",
|
||||||
|
|
@ -114,12 +139,12 @@ func init() {
|
||||||
"search.no_results": "No references found",
|
"search.no_results": "No references found",
|
||||||
|
|
||||||
// Search Intent: assistant/search.go - Intent detection messages
|
// Search Intent: assistant/search.go - Intent detection messages
|
||||||
"search.intent.loading": "Checking if references are needed...",
|
"search.intent.loading": "Checking if references are needed",
|
||||||
"search.intent.need_search": "Searching for references...",
|
"search.intent.need_search": "Searching for references",
|
||||||
"search.intent.no_search": "No references needed",
|
"search.intent.no_search": "No references needed",
|
||||||
|
|
||||||
// Keyword Extraction: assistant/search.go - Keyword extraction messages
|
// Keyword Extraction: assistant/search.go - Keyword extraction messages
|
||||||
"search.keyword.loading": "Analyzing conversation...",
|
"search.keyword.loading": "Analyzing conversation",
|
||||||
"search.keyword.done": "Analysis complete",
|
"search.keyword.done": "Analysis complete",
|
||||||
|
|
||||||
// Search: assistant/search.go - Trace labels
|
// Search: assistant/search.go - Trace labels
|
||||||
|
|
@ -198,14 +223,39 @@ func init() {
|
||||||
"kb.chat.name": "聊天知识库",
|
"kb.chat.name": "聊天知识库",
|
||||||
"kb.chat.description": "自动为聊天会话创建的知识库集合",
|
"kb.chat.description": "自动为聊天会话创建的知识库集合",
|
||||||
|
|
||||||
|
// Sandbox: assistant/sandbox.go - Sandbox status messages
|
||||||
|
"sandbox.preparing": "正在准备沙箱环境",
|
||||||
|
"sandbox.ready": "沙箱环境就绪",
|
||||||
|
"sandbox.working": "正在处理您的请求",
|
||||||
|
"sandbox.completed": "处理完成",
|
||||||
|
"sandbox.failed": "执行失败",
|
||||||
|
|
||||||
|
// Sandbox: claude/executor.go - Tool execution messages
|
||||||
|
"sandbox.tool.read": "正在读取文件",
|
||||||
|
"sandbox.tool.write": "正在写入文件",
|
||||||
|
"sandbox.tool.edit": "正在编辑文件",
|
||||||
|
"sandbox.tool.bash": "正在执行命令",
|
||||||
|
"sandbox.tool.glob": "正在查找文件",
|
||||||
|
"sandbox.tool.grep": "正在搜索代码",
|
||||||
|
"sandbox.tool.ls": "正在列出目录",
|
||||||
|
"sandbox.tool.task": "正在执行子任务",
|
||||||
|
"sandbox.tool.web_search": "正在搜索网页",
|
||||||
|
"sandbox.tool.web_fetch": "正在获取网页",
|
||||||
|
"sandbox.tool.todo_write": "正在管理任务",
|
||||||
|
"sandbox.tool.ask_question": "正在询问问题",
|
||||||
|
"sandbox.tool.switch_mode": "正在切换模式",
|
||||||
|
"sandbox.tool.read_lints": "正在检查代码",
|
||||||
|
"sandbox.tool.edit_notebook": "正在编辑笔记本",
|
||||||
|
"sandbox.tool.unknown": "正在执行 {{name}}",
|
||||||
|
|
||||||
// Content: content/image/image.go - Image processing messages
|
// Content: content/image/image.go - Image processing messages
|
||||||
"content.image.analyzing": "正在分析图片...",
|
"content.image.analyzing": "正在分析图片",
|
||||||
|
|
||||||
// Content: content/pdf/pdf.go - PDF processing messages
|
// Content: content/pdf/pdf.go - PDF processing messages
|
||||||
"content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页...",
|
"content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页",
|
||||||
|
|
||||||
// Search: assistant/search.go - Output messages
|
// Search: assistant/search.go - Output messages
|
||||||
"search.loading": "正在搜索...",
|
"search.loading": "正在搜索",
|
||||||
"search.success": "找到 %d 条参考资料",
|
"search.success": "找到 %d 条参考资料",
|
||||||
"search.success.one": "找到 1 条参考资料",
|
"search.success.one": "找到 1 条参考资料",
|
||||||
"search.partial": "找到 %d 条参考资料(部分来源失败)",
|
"search.partial": "找到 %d 条参考资料(部分来源失败)",
|
||||||
|
|
@ -213,12 +263,12 @@ func init() {
|
||||||
"search.no_results": "未找到相关资料",
|
"search.no_results": "未找到相关资料",
|
||||||
|
|
||||||
// Search Intent: assistant/search.go - Intent detection messages
|
// Search Intent: assistant/search.go - Intent detection messages
|
||||||
"search.intent.loading": "检查是否需要查询资料...",
|
"search.intent.loading": "检查是否需要查询资料",
|
||||||
"search.intent.need_search": "正在查询相关资料...",
|
"search.intent.need_search": "正在查询相关资料",
|
||||||
"search.intent.no_search": "无需查询资料",
|
"search.intent.no_search": "无需查询资料",
|
||||||
|
|
||||||
// Keyword Extraction: assistant/search.go - Keyword extraction messages
|
// Keyword Extraction: assistant/search.go - Keyword extraction messages
|
||||||
"search.keyword.loading": "正在分析对话内容...",
|
"search.keyword.loading": "正在分析对话内容",
|
||||||
"search.keyword.done": "分析完成",
|
"search.keyword.done": "分析完成",
|
||||||
|
|
||||||
// Search: assistant/search.go - Trace labels
|
// Search: assistant/search.go - Trace labels
|
||||||
|
|
@ -325,14 +375,39 @@ func init() {
|
||||||
"kb.chat.name": "聊天知识库",
|
"kb.chat.name": "聊天知识库",
|
||||||
"kb.chat.description": "自动为聊天会话创建的知识库集合",
|
"kb.chat.description": "自动为聊天会话创建的知识库集合",
|
||||||
|
|
||||||
|
// Sandbox: assistant/sandbox.go - Sandbox status messages
|
||||||
|
"sandbox.preparing": "正在准备沙箱环境",
|
||||||
|
"sandbox.ready": "沙箱环境就绪",
|
||||||
|
"sandbox.working": "正在处理您的请求",
|
||||||
|
"sandbox.completed": "处理完成",
|
||||||
|
"sandbox.failed": "执行失败",
|
||||||
|
|
||||||
|
// Sandbox: claude/executor.go - Tool execution messages
|
||||||
|
"sandbox.tool.read": "正在读取文件",
|
||||||
|
"sandbox.tool.write": "正在写入文件",
|
||||||
|
"sandbox.tool.edit": "正在编辑文件",
|
||||||
|
"sandbox.tool.bash": "正在执行命令",
|
||||||
|
"sandbox.tool.glob": "正在查找文件",
|
||||||
|
"sandbox.tool.grep": "正在搜索代码",
|
||||||
|
"sandbox.tool.ls": "正在列出目录",
|
||||||
|
"sandbox.tool.task": "正在执行子任务",
|
||||||
|
"sandbox.tool.web_search": "正在搜索网页",
|
||||||
|
"sandbox.tool.web_fetch": "正在获取网页",
|
||||||
|
"sandbox.tool.todo_write": "正在管理任务",
|
||||||
|
"sandbox.tool.ask_question": "正在询问问题",
|
||||||
|
"sandbox.tool.switch_mode": "正在切换模式",
|
||||||
|
"sandbox.tool.read_lints": "正在检查代码",
|
||||||
|
"sandbox.tool.edit_notebook": "正在编辑笔记本",
|
||||||
|
"sandbox.tool.unknown": "正在执行 {{name}}",
|
||||||
|
|
||||||
// Content: content/image/image.go - Image processing messages
|
// Content: content/image/image.go - Image processing messages
|
||||||
"content.image.analyzing": "正在分析图片...",
|
"content.image.analyzing": "正在分析图片",
|
||||||
|
|
||||||
// Content: content/pdf/pdf.go - PDF processing messages
|
// Content: content/pdf/pdf.go - PDF processing messages
|
||||||
"content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页...",
|
"content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页",
|
||||||
|
|
||||||
// Search: assistant/search.go - Output messages
|
// Search: assistant/search.go - Output messages
|
||||||
"search.loading": "正在搜索...",
|
"search.loading": "正在搜索",
|
||||||
"search.success": "找到 %d 条参考资料",
|
"search.success": "找到 %d 条参考资料",
|
||||||
"search.success.one": "找到 1 条参考资料",
|
"search.success.one": "找到 1 条参考资料",
|
||||||
"search.partial": "找到 %d 条参考资料(部分来源失败)",
|
"search.partial": "找到 %d 条参考资料(部分来源失败)",
|
||||||
|
|
@ -340,12 +415,12 @@ func init() {
|
||||||
"search.no_results": "未找到相关资料",
|
"search.no_results": "未找到相关资料",
|
||||||
|
|
||||||
// Search Intent: assistant/search.go - Intent detection messages
|
// Search Intent: assistant/search.go - Intent detection messages
|
||||||
"search.intent.loading": "检查是否需要查询资料...",
|
"search.intent.loading": "检查是否需要查询资料",
|
||||||
"search.intent.need_search": "正在查询相关资料...",
|
"search.intent.need_search": "正在查询相关资料",
|
||||||
"search.intent.no_search": "无需查询资料",
|
"search.intent.no_search": "无需查询资料",
|
||||||
|
|
||||||
// Keyword Extraction: assistant/search.go - Keyword extraction messages
|
// Keyword Extraction: assistant/search.go - Keyword extraction messages
|
||||||
"search.keyword.loading": "正在分析对话内容...",
|
"search.keyword.loading": "正在分析对话内容",
|
||||||
"search.keyword.done": "分析完成",
|
"search.keyword.done": "分析完成",
|
||||||
|
|
||||||
// Search: assistant/search.go - Trace labels
|
// Search: assistant/search.go - Trace labels
|
||||||
|
|
|
||||||
|
|
@ -8,44 +8,117 @@ import (
|
||||||
agentContext "github.com/yaoapp/yao/agent/context"
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// sandboxEnvPrompt is the system prompt injected for sandbox environment
|
||||||
|
// This tells Claude CLI about the workspace and project structure
|
||||||
|
const sandboxEnvPrompt = `## Sandbox Environment
|
||||||
|
|
||||||
|
You are running in a sandboxed environment with the following setup:
|
||||||
|
|
||||||
|
- **Working Directory**: /workspace
|
||||||
|
- **Project Structure**: If this is a new project, create a dedicated project folder (e.g., /workspace/my-project/) and work inside it
|
||||||
|
- **File Access**: You have full read/write access to /workspace
|
||||||
|
- **Output Files**: Save all output files to the working directory
|
||||||
|
|
||||||
|
When creating new projects:
|
||||||
|
1. Create a project directory with a descriptive name
|
||||||
|
2. Initialize the project structure inside that directory
|
||||||
|
3. Keep all related files organized within the project folder
|
||||||
|
|
||||||
|
## IMPORTANT: Restricted Tools
|
||||||
|
|
||||||
|
The following tools are NOT available in this environment and you must NOT use them:
|
||||||
|
- EnterPlanMode, ExitPlanMode (use regular text to explain plans instead)
|
||||||
|
- Task, TaskOutput, TaskStop (complete tasks directly without delegation)
|
||||||
|
- AskUserQuestion (make reasonable assumptions instead of asking)
|
||||||
|
- Skill, ToolSearch (not supported)
|
||||||
|
|
||||||
|
Focus on using the core tools: Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch.
|
||||||
|
|
||||||
|
## GitHub CLI (gh) Usage
|
||||||
|
|
||||||
|
When working with GitHub and a token is provided:
|
||||||
|
1. First authenticate gh CLI using the token: echo "TOKEN" | gh auth login --with-token
|
||||||
|
2. Then use gh commands normally (gh repo create, gh pr create, etc.)
|
||||||
|
3. Do NOT use curl to call GitHub API directly - always prefer gh CLI
|
||||||
|
`
|
||||||
|
|
||||||
// BuildCommand builds the Claude CLI command and environment variables
|
// BuildCommand builds the Claude CLI command and environment variables
|
||||||
|
// Uses stdin with --input-format stream-json for unlimited prompt length
|
||||||
func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) {
|
func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) {
|
||||||
// Build system prompt from conversation history
|
// Build system prompt from conversation history
|
||||||
systemPrompt, userPrompt := buildPrompts(messages)
|
systemPrompt, _ := buildPrompts(messages)
|
||||||
|
|
||||||
// Build the ccr code command with all arguments
|
// Inject sandbox environment prompt
|
||||||
// We use bash -c to ensure CCR is started first, then run ccr code with proper argument handling
|
if systemPrompt != "" {
|
||||||
var ccrArgs []string
|
systemPrompt = systemPrompt + "\n\n" + sandboxEnvPrompt
|
||||||
|
} else {
|
||||||
|
systemPrompt = sandboxEnvPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build input JSONL for Claude CLI (stream-json format)
|
||||||
|
inputJSONL, err := BuildInputJSONL(messages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to build input JSONL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build Claude CLI arguments
|
||||||
|
var claudeArgs []string
|
||||||
|
|
||||||
// Add permission mode (required for MCP tools to work)
|
// Add permission mode (required for MCP tools to work)
|
||||||
permMode := "acceptEdits" // default
|
permMode := "bypassPermissions" // default for sandbox
|
||||||
if opts != nil && opts.Arguments != nil {
|
if opts != nil && opts.Arguments != nil {
|
||||||
if mode, ok := opts.Arguments["permission_mode"].(string); ok && mode != "" {
|
if mode, ok := opts.Arguments["permission_mode"].(string); ok && mode != "" {
|
||||||
permMode = mode
|
permMode = mode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ccrArgs = append(ccrArgs, "--permission-mode", permMode)
|
claudeArgs = append(claudeArgs, "--dangerously-skip-permissions")
|
||||||
|
claudeArgs = append(claudeArgs, "--permission-mode", permMode)
|
||||||
|
|
||||||
|
// Add streaming format flags (required for proper streaming output)
|
||||||
|
claudeArgs = append(claudeArgs, "--input-format", "stream-json")
|
||||||
|
claudeArgs = append(claudeArgs, "--output-format", "stream-json")
|
||||||
|
claudeArgs = append(claudeArgs, "--include-partial-messages") // Enable realtime streaming
|
||||||
|
claudeArgs = append(claudeArgs, "--verbose")
|
||||||
|
|
||||||
|
// Add max_turns if specified
|
||||||
|
if opts != nil && opts.Arguments != nil {
|
||||||
|
if maxTurns, ok := opts.Arguments["max_turns"]; ok {
|
||||||
|
claudeArgs = append(claudeArgs, "--max-turns", fmt.Sprintf("%v", maxTurns))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add MCP config if available
|
// Add MCP config if available
|
||||||
if opts != nil && len(opts.MCPConfig) > 0 {
|
if opts != nil && len(opts.MCPConfig) > 0 {
|
||||||
ccrArgs = append(ccrArgs, "--mcp-config", "/workspace/.mcp.json")
|
claudeArgs = append(claudeArgs, "--mcp-config", "/workspace/.mcp.json")
|
||||||
// Allow all tools from the "yao" MCP server
|
// Allow all tools from the "yao" MCP server
|
||||||
ccrArgs = append(ccrArgs, "--allowedTools", "mcp__yao__*")
|
claudeArgs = append(claudeArgs, "--allowedTools", "mcp__yao__*")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the full bash command
|
// Build the full bash command
|
||||||
// Start CCR daemon, wait, then run ccr code with arguments
|
// Use heredoc for both system prompt and input JSONL to avoid shell escaping issues
|
||||||
bashCmd := "nohup ccr start >/dev/null 2>&1 & sleep 2; ccr code"
|
// System prompt may contain quotes, newlines, special characters that break shell quoting
|
||||||
for _, arg := range ccrArgs {
|
var bashCmd strings.Builder
|
||||||
// Quote arguments that might contain special characters
|
|
||||||
bashCmd += fmt.Sprintf(" %q", arg)
|
// If we have a system prompt, write it to a temp file via heredoc first
|
||||||
}
|
// then use --append-system-prompt-file
|
||||||
bashCmd += " -p"
|
if systemPrompt != "" {
|
||||||
if userPrompt != "" {
|
bashCmd.WriteString("cat << 'PROMPTEOF' > /tmp/.system-prompt.txt\n")
|
||||||
bashCmd += fmt.Sprintf(" %q", userPrompt)
|
bashCmd.WriteString(systemPrompt)
|
||||||
|
bashCmd.WriteString("\nPROMPTEOF\n")
|
||||||
|
claudeArgs = append(claudeArgs, "--append-system-prompt-file", "/tmp/.system-prompt.txt")
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := []string{"bash", "-c", bashCmd}
|
// Build claude command with all arguments
|
||||||
|
bashCmd.WriteString("cat << 'INPUTEOF' | claude -p")
|
||||||
|
for _, arg := range claudeArgs {
|
||||||
|
// Quote arguments that might contain special characters
|
||||||
|
bashCmd.WriteString(fmt.Sprintf(" %q", arg))
|
||||||
|
}
|
||||||
|
bashCmd.WriteString("\n")
|
||||||
|
bashCmd.WriteString(string(inputJSONL))
|
||||||
|
bashCmd.WriteString("\nINPUTEOF")
|
||||||
|
|
||||||
|
cmd := []string{"bash", "-c", bashCmd.String()}
|
||||||
|
|
||||||
// Build environment variables
|
// Build environment variables
|
||||||
env := buildEnvironment(opts, systemPrompt)
|
env := buildEnvironment(opts, systemPrompt)
|
||||||
|
|
@ -53,6 +126,44 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map
|
||||||
return cmd, env, nil
|
return cmd, env, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildInputJSONL converts messages to Claude CLI stream-json input format
|
||||||
|
// Each message becomes a line in JSONL format
|
||||||
|
func BuildInputJSONL(messages []agentContext.Message) ([]byte, error) {
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
// Skip system messages (handled via --system-prompt or env var)
|
||||||
|
if msg.Role == "system" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the message content
|
||||||
|
var content interface{}
|
||||||
|
if msg.Content != nil {
|
||||||
|
content = msg.Content
|
||||||
|
} else {
|
||||||
|
content = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stream-json message
|
||||||
|
streamMsg := map[string]interface{}{
|
||||||
|
"type": msg.Role, // "user" or "assistant"
|
||||||
|
"message": map[string]interface{}{
|
||||||
|
"role": msg.Role,
|
||||||
|
"content": content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(streamMsg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal message: %w", err)
|
||||||
|
}
|
||||||
|
lines = append(lines, string(jsonBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(strings.Join(lines, "\n")), nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildPrompts extracts system prompt and user prompt from messages
|
// buildPrompts extracts system prompt and user prompt from messages
|
||||||
func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) {
|
func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) {
|
||||||
var systemParts []string
|
var systemParts []string
|
||||||
|
|
@ -123,118 +234,54 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
// CCR configuration via environment
|
// claude-proxy runs on localhost:3456, Claude CLI connects to it
|
||||||
// CCR (Claude Code Router) transforms OpenAI-compatible API to Anthropic API format
|
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
|
||||||
if opts.ConnectorHost != "" {
|
env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this
|
||||||
// CCR expects ANTHROPIC_BASE_URL but will proxy through its own router
|
|
||||||
env["CCR_API_BASE"] = opts.ConnectorHost
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.ConnectorKey != "" {
|
// Note: System prompt and max_turns are passed via CLI flags in BuildCommand
|
||||||
env["CCR_API_KEY"] = opts.ConnectorKey
|
// CLAUDE_SYSTEM_PROMPT environment variable is NOT supported by Claude CLI
|
||||||
}
|
// --append-system-prompt or --system-prompt flags must be used instead
|
||||||
|
|
||||||
if opts.Model != "" {
|
|
||||||
env["CCR_MODEL"] = opts.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set system prompt via environment (Claude CLI supports this)
|
|
||||||
if systemPrompt != "" {
|
|
||||||
env["CLAUDE_SYSTEM_PROMPT"] = systemPrompt
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional Claude CLI options from Arguments
|
|
||||||
if opts.Arguments != nil {
|
|
||||||
// max_turns
|
|
||||||
if maxTurns, ok := opts.Arguments["max_turns"]; ok {
|
|
||||||
env["CLAUDE_MAX_TURNS"] = fmt.Sprintf("%v", maxTurns)
|
|
||||||
}
|
|
||||||
|
|
||||||
// permission_mode
|
|
||||||
if permMode, ok := opts.Arguments["permission_mode"].(string); ok {
|
|
||||||
env["CLAUDE_PERMISSION_MODE"] = permMode
|
|
||||||
}
|
|
||||||
|
|
||||||
// output_format (default to stream-json for streaming)
|
|
||||||
if outputFormat, ok := opts.Arguments["output_format"].(string); ok {
|
|
||||||
env["CLAUDE_OUTPUT_FORMAT"] = outputFormat
|
|
||||||
} else {
|
|
||||||
env["CLAUDE_OUTPUT_FORMAT"] = "stream-json"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
env["CLAUDE_OUTPUT_FORMAT"] = "stream-json"
|
|
||||||
}
|
|
||||||
|
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildCCRConfig builds the CCR (Claude Code Router) configuration JSON
|
// BuildProxyConfig builds the claude-proxy configuration JSON
|
||||||
// CCR requires a specific format with Providers array and Router configuration
|
// This config file is read by start-claude-proxy script in the container
|
||||||
func BuildCCRConfig(opts *Options) ([]byte, error) {
|
// Config is written to /tmp/.yao/proxy.json (not /workspace/) for security
|
||||||
|
func BuildProxyConfig(opts *Options) ([]byte, error) {
|
||||||
if opts == nil {
|
if opts == nil {
|
||||||
return nil, fmt.Errorf("options is required")
|
return nil, fmt.Errorf("options is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine provider name based on host
|
// Build backend URL - ensure it ends with /chat/completions
|
||||||
providerName := "custom"
|
backendURL := opts.ConnectorHost
|
||||||
apiBaseURL := opts.ConnectorHost
|
if !strings.HasSuffix(backendURL, "/chat/completions") {
|
||||||
needsTransformer := false
|
backendURL = strings.TrimSuffix(backendURL, "/") + "/chat/completions"
|
||||||
|
|
||||||
if strings.Contains(opts.ConnectorHost, "volces.com") || strings.Contains(opts.ConnectorHost, "volcengine") {
|
|
||||||
providerName = "volcengine"
|
|
||||||
needsTransformer = true
|
|
||||||
// Ensure URL ends with chat/completions
|
|
||||||
if !strings.HasSuffix(apiBaseURL, "/chat/completions") {
|
|
||||||
apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/chat/completions"
|
|
||||||
}
|
|
||||||
} else if strings.Contains(opts.ConnectorHost, "deepseek") {
|
|
||||||
providerName = "deepseek"
|
|
||||||
needsTransformer = true
|
|
||||||
if !strings.HasSuffix(apiBaseURL, "/chat/completions") {
|
|
||||||
apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/chat/completions"
|
|
||||||
}
|
|
||||||
} else if strings.Contains(opts.ConnectorHost, "openai.com") {
|
|
||||||
providerName = "openai"
|
|
||||||
if !strings.HasSuffix(apiBaseURL, "/chat/completions") {
|
|
||||||
apiBaseURL = strings.TrimSuffix(apiBaseURL, "/") + "/v1/chat/completions"
|
|
||||||
}
|
|
||||||
} else if strings.Contains(opts.ConnectorHost, "anthropic.com") {
|
|
||||||
providerName = "claude"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build provider configuration
|
|
||||||
provider := map[string]interface{}{
|
|
||||||
"name": providerName,
|
|
||||||
"api_base_url": apiBaseURL,
|
|
||||||
"api_key": opts.ConnectorKey,
|
|
||||||
"models": []string{opts.Model},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add transformer for providers that need it (DeepSeek, Volcengine)
|
|
||||||
if needsTransformer {
|
|
||||||
provider["transformer"] = map[string]interface{}{
|
|
||||||
"use": []interface{}{
|
|
||||||
[]interface{}{"maxtoken", map[string]interface{}{"max_tokens": 16384}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build router configuration
|
|
||||||
routerKey := fmt.Sprintf("%s,%s", providerName, opts.Model)
|
|
||||||
router := map[string]interface{}{
|
|
||||||
"default": routerKey,
|
|
||||||
"background": routerKey,
|
|
||||||
"think": routerKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build full config
|
|
||||||
config := map[string]interface{}{
|
config := map[string]interface{}{
|
||||||
"LOG": true,
|
"backend": backendURL,
|
||||||
"API_TIMEOUT_MS": 600000,
|
"api_key": opts.ConnectorKey,
|
||||||
"NON_INTERACTIVE_MODE": true,
|
"model": opts.Model,
|
||||||
"Providers": []interface{}{provider},
|
}
|
||||||
"Router": router,
|
|
||||||
|
// Add extra connector options if present (e.g., thinking, max_tokens, temperature)
|
||||||
|
// These will be passed to the proxy via CLAUDE_PROXY_OPTIONS environment variable
|
||||||
|
if len(opts.ConnectorOptions) > 0 {
|
||||||
|
config["options"] = opts.ConnectorOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add secrets if present (e.g., GITHUB_TOKEN, AWS_ACCESS_KEY)
|
||||||
|
// These will be exported as environment variables for Claude CLI to use
|
||||||
|
if len(opts.Secrets) > 0 {
|
||||||
|
config["secrets"] = opts.Secrets
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.MarshalIndent(config, "", " ")
|
return json.MarshalIndent(config, "", " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildCCRConfig is deprecated, kept for backward compatibility
|
||||||
|
// Use BuildProxyConfig instead
|
||||||
|
func BuildCCRConfig(opts *Options) ([]byte, error) {
|
||||||
|
return BuildProxyConfig(opts)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package claude
|
package claude
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -24,16 +25,21 @@ func TestBuildCommand(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify command structure
|
// Verify command structure
|
||||||
// Command is now: ["bash", "-c", "nohup ccr start ... ; ccr code ... -p \"prompt\""]
|
// Command is now: ["bash", "-c", "cat << 'INPUTEOF' | claude -p ... INPUTEOF"]
|
||||||
assert.Equal(t, "bash", cmd[0])
|
assert.Equal(t, "bash", cmd[0])
|
||||||
assert.Equal(t, "-c", cmd[1])
|
assert.Equal(t, "-c", cmd[1])
|
||||||
assert.Contains(t, cmd[2], "Hello") // User prompt should be in bash command
|
// User message should be in bash command (as JSONL via stdin)
|
||||||
|
assert.Contains(t, cmd[2], "Hello")
|
||||||
|
// Should have stream-json flags
|
||||||
|
assert.Contains(t, cmd[2], "--input-format")
|
||||||
|
assert.Contains(t, cmd[2], "--output-format")
|
||||||
|
assert.Contains(t, cmd[2], "--include-partial-messages")
|
||||||
|
assert.Contains(t, cmd[2], "--verbose")
|
||||||
|
assert.Contains(t, cmd[2], "stream-json")
|
||||||
|
|
||||||
// Verify environment variables
|
// Verify environment variables (claude-proxy)
|
||||||
assert.Equal(t, "https://api.example.com", env["CCR_API_BASE"])
|
assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"])
|
||||||
assert.Equal(t, "key123", env["CCR_API_KEY"])
|
assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"])
|
||||||
assert.Equal(t, "test-model", env["CCR_MODEL"])
|
|
||||||
assert.Equal(t, "stream-json", env["CLAUDE_OUTPUT_FORMAT"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildCommandWithSystemPrompt(t *testing.T) {
|
func TestBuildCommandWithSystemPrompt(t *testing.T) {
|
||||||
|
|
@ -46,12 +52,35 @@ func TestBuildCommandWithSystemPrompt(t *testing.T) {
|
||||||
|
|
||||||
opts := &Options{}
|
opts := &Options{}
|
||||||
|
|
||||||
_, env, err := BuildCommand(messages, opts)
|
cmd, _, err := BuildCommand(messages, opts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// System prompt should include conversation history
|
// System prompt should be written to file via heredoc, then passed via --append-system-prompt-file
|
||||||
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a code reviewer")
|
bashCmd := cmd[2] // The bash -c command string
|
||||||
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "Conversation History")
|
assert.Contains(t, bashCmd, "cat << 'PROMPTEOF' > /tmp/.system-prompt.txt")
|
||||||
|
assert.Contains(t, bashCmd, "You are a code reviewer")
|
||||||
|
assert.Contains(t, bashCmd, "PROMPTEOF")
|
||||||
|
assert.Contains(t, bashCmd, "--append-system-prompt-file")
|
||||||
|
assert.Contains(t, bashCmd, "/tmp/.system-prompt.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCommandWithSpecialCharsInPrompt(t *testing.T) {
|
||||||
|
// Test that special characters in prompts are handled correctly
|
||||||
|
messages := []agentContext.Message{
|
||||||
|
{Role: "system", Content: "You are a helper.\n\n## Rules\n- Rule 1: Don't use \"quotes\" wrongly\n- Rule 2: Handle 'single quotes' too\n- Rule 3: Special chars like $VAR and `backticks`"},
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &Options{}
|
||||||
|
|
||||||
|
cmd, _, err := BuildCommand(messages, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
bashCmd := cmd[2]
|
||||||
|
// The heredoc approach should preserve all special characters
|
||||||
|
assert.Contains(t, bashCmd, "## Rules")
|
||||||
|
assert.Contains(t, bashCmd, `Don't use "quotes" wrongly`)
|
||||||
|
assert.Contains(t, bashCmd, "'single quotes'")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildCommandWithArguments(t *testing.T) {
|
func TestBuildCommandWithArguments(t *testing.T) {
|
||||||
|
|
@ -63,59 +92,115 @@ func TestBuildCommandWithArguments(t *testing.T) {
|
||||||
Arguments: map[string]interface{}{
|
Arguments: map[string]interface{}{
|
||||||
"max_turns": 20,
|
"max_turns": 20,
|
||||||
"permission_mode": "acceptEdits",
|
"permission_mode": "acceptEdits",
|
||||||
"output_format": "json",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, env, err := BuildCommand(messages, opts)
|
cmd, _, err := BuildCommand(messages, opts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "20", env["CLAUDE_MAX_TURNS"])
|
bashCmd := cmd[2] // The bash -c command string
|
||||||
assert.Equal(t, "acceptEdits", env["CLAUDE_PERMISSION_MODE"])
|
// max_turns should be in command args via --max-turns
|
||||||
assert.Equal(t, "json", env["CLAUDE_OUTPUT_FORMAT"])
|
assert.Contains(t, bashCmd, "--max-turns")
|
||||||
|
assert.Contains(t, bashCmd, "20")
|
||||||
|
// permission_mode should be in command args
|
||||||
|
assert.Contains(t, bashCmd, "acceptEdits")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildCCRConfig(t *testing.T) {
|
func TestBuildProxyConfig(t *testing.T) {
|
||||||
opts := &Options{
|
opts := &Options{
|
||||||
ConnectorHost: "https://api.example.com",
|
ConnectorHost: "https://api.example.com",
|
||||||
ConnectorKey: "key123",
|
ConnectorKey: "key123",
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
}
|
}
|
||||||
|
|
||||||
configJSON, err := BuildCCRConfig(opts)
|
configJSON, err := BuildProxyConfig(opts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
configStr := string(configJSON)
|
configStr := string(configJSON)
|
||||||
// CCR config uses snake_case for fields
|
// Proxy config uses simple format
|
||||||
assert.Contains(t, configStr, "api_base_url")
|
assert.Contains(t, configStr, "backend")
|
||||||
assert.Contains(t, configStr, "https://api.example.com")
|
assert.Contains(t, configStr, "https://api.example.com/chat/completions")
|
||||||
assert.Contains(t, configStr, "api_key")
|
assert.Contains(t, configStr, "api_key")
|
||||||
assert.Contains(t, configStr, "key123")
|
assert.Contains(t, configStr, "key123")
|
||||||
assert.Contains(t, configStr, "models")
|
assert.Contains(t, configStr, "model")
|
||||||
assert.Contains(t, configStr, "test-model")
|
assert.Contains(t, configStr, "test-model")
|
||||||
// Verify new CCR format fields
|
|
||||||
assert.Contains(t, configStr, "Providers")
|
|
||||||
assert.Contains(t, configStr, "Router")
|
|
||||||
assert.Contains(t, configStr, "NON_INTERACTIVE_MODE")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildCCRConfigVolcengine(t *testing.T) {
|
func TestBuildProxyConfigVolcengine(t *testing.T) {
|
||||||
opts := &Options{
|
opts := &Options{
|
||||||
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3/",
|
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3/",
|
||||||
ConnectorKey: "test-key",
|
ConnectorKey: "test-key",
|
||||||
Model: "ep-xxx",
|
Model: "ep-xxx",
|
||||||
}
|
}
|
||||||
|
|
||||||
configJSON, err := BuildCCRConfig(opts)
|
configJSON, err := BuildProxyConfig(opts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
configStr := string(configJSON)
|
configStr := string(configJSON)
|
||||||
// Verify volcengine-specific configuration
|
|
||||||
assert.Contains(t, configStr, "volcengine")
|
|
||||||
assert.Contains(t, configStr, "transformer")
|
|
||||||
assert.Contains(t, configStr, "maxtoken")
|
|
||||||
// URL should end with /chat/completions
|
// URL should end with /chat/completions
|
||||||
assert.Contains(t, configStr, "/chat/completions")
|
assert.Contains(t, configStr, "/chat/completions")
|
||||||
|
assert.Contains(t, configStr, "ep-xxx")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildInputJSONL(t *testing.T) {
|
||||||
|
messages := []agentContext.Message{
|
||||||
|
{Role: "system", Content: "You are helpful"},
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
{Role: "assistant", Content: "Hi there!"},
|
||||||
|
{Role: "user", Content: "How are you?"},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonl, err := BuildInputJSONL(messages)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should not contain system messages (handled separately)
|
||||||
|
assert.NotContains(t, string(jsonl), "You are helpful")
|
||||||
|
|
||||||
|
// Should contain user and assistant messages
|
||||||
|
assert.Contains(t, string(jsonl), "Hello")
|
||||||
|
assert.Contains(t, string(jsonl), "Hi there!")
|
||||||
|
assert.Contains(t, string(jsonl), "How are you?")
|
||||||
|
|
||||||
|
// Verify JSONL format (each line is valid JSON)
|
||||||
|
lines := splitLines(string(jsonl))
|
||||||
|
for _, line := range lines {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var msg map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(line), &msg)
|
||||||
|
assert.NoError(t, err, "Line should be valid JSON: %s", line)
|
||||||
|
assert.Contains(t, msg, "type")
|
||||||
|
assert.Contains(t, msg, "message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildInputJSONLMultimodal(t *testing.T) {
|
||||||
|
// Test with multimodal content (image)
|
||||||
|
messages := []agentContext.Message{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: []interface{}{
|
||||||
|
map[string]interface{}{"type": "text", "text": "What's in this image?"},
|
||||||
|
map[string]interface{}{
|
||||||
|
"type": "image",
|
||||||
|
"source": map[string]interface{}{
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/png",
|
||||||
|
"data": "iVBORw0KGgo=",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonl, err := BuildInputJSONL(messages)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should contain the multimodal content
|
||||||
|
assert.Contains(t, string(jsonl), "What's in this image?")
|
||||||
|
assert.Contains(t, string(jsonl), "image")
|
||||||
|
assert.Contains(t, string(jsonl), "base64")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetMessageContent(t *testing.T) {
|
func TestGetMessageContent(t *testing.T) {
|
||||||
|
|
@ -137,3 +222,19 @@ func TestGetMessageContent(t *testing.T) {
|
||||||
assert.Contains(t, getMessageContent(msg3), "Part 1")
|
assert.Contains(t, getMessageContent(msg3), "Part 1")
|
||||||
assert.Contains(t, getMessageContent(msg3), "Part 2")
|
assert.Contains(t, getMessageContent(msg3), "Part 2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to split lines
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
var lines []string
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '\n' {
|
||||||
|
lines = append(lines, s[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < len(s) {
|
||||||
|
lines = append(lines, s[start:])
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
|
||||||
275
agent/sandbox/claude/e2e_test.go
Normal file
275
agent/sandbox/claude/e2e_test.go
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestE2ESkipClaudeCLI verifies that Claude CLI is skipped when no prompts/skills/mcp
|
||||||
|
// This is the "hook-only" mode where hooks take full control
|
||||||
|
func TestE2ESkipClaudeCLI(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping E2E test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
manager := createTestManager(t)
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Create options WITHOUT SystemPrompt, SkillsDir, or MCPConfig
|
||||||
|
// This should trigger the skip logic
|
||||||
|
opts := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "alpine:latest", // Use alpine since we're not calling Claude CLI
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: fmt.Sprintf("test-e2e-skip-%d", time.Now().UnixNano()),
|
||||||
|
ConnectorHost: "",
|
||||||
|
ConnectorKey: "",
|
||||||
|
Model: "",
|
||||||
|
// No SystemPrompt, SkillsDir, or MCPConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
exec, err := NewExecutor(manager, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer exec.Close()
|
||||||
|
|
||||||
|
// Verify shouldSkipClaudeCLI returns true
|
||||||
|
assert.True(t, exec.shouldSkipClaudeCLI(), "Should skip Claude CLI when no prompts/skills/mcp")
|
||||||
|
|
||||||
|
// Execute Stream - it should return immediately without calling Claude CLI
|
||||||
|
ctx := agentContext.New(context.Background(), nil, opts.ChatID)
|
||||||
|
messages := []agentContext.Message{
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := exec.Stream(ctx, messages, nil)
|
||||||
|
require.NoError(t, err, "Stream should succeed")
|
||||||
|
require.NotNil(t, response, "Response should not be nil")
|
||||||
|
|
||||||
|
// Verify response indicates skip
|
||||||
|
assert.Contains(t, response.ID, "sandbox-skip", "Response ID should indicate skip")
|
||||||
|
assert.Equal(t, "sandbox", response.Model, "Model should be 'sandbox' for skip mode")
|
||||||
|
assert.Empty(t, response.Content, "Content should be empty for skip mode")
|
||||||
|
|
||||||
|
t.Log("✓ Claude CLI skip mode verified")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestE2EExecuteClaudeCLI verifies that Claude CLI is called when prompts are configured
|
||||||
|
// This requires the real yaoapp/sandbox-claude image and a valid connector
|
||||||
|
func TestE2EExecuteClaudeCLI(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping E2E test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for required environment variables
|
||||||
|
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||||
|
apiProxy := os.Getenv("DEEPSEEK_API_PROXY")
|
||||||
|
model := os.Getenv("DEEPSEEK_MODELS_V3")
|
||||||
|
|
||||||
|
if apiKey == "" || apiProxy == "" || model == "" {
|
||||||
|
t.Skip("Skipping test: DEEPSEEK_API_KEY, DEEPSEEK_API_PROXY, or DEEPSEEK_MODELS_V3 not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Get data root from environment
|
||||||
|
dataRoot := os.Getenv("YAO_ROOT")
|
||||||
|
if dataRoot == "" {
|
||||||
|
t.Skip("Skipping test: YAO_ROOT not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config with proper paths
|
||||||
|
cfg := infraSandbox.DefaultConfig()
|
||||||
|
cfg.Init(dataRoot)
|
||||||
|
|
||||||
|
manager, err := infraSandbox.NewManager(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: Docker not available: %v", err)
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Create options WITH SystemPrompt (triggers Claude CLI execution)
|
||||||
|
opts := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "yaoapp/sandbox-claude:latest",
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: fmt.Sprintf("test-e2e-exec-%d", time.Now().UnixNano()),
|
||||||
|
ConnectorHost: apiProxy,
|
||||||
|
ConnectorKey: apiKey,
|
||||||
|
Model: model,
|
||||||
|
SystemPrompt: "You are a helpful assistant. Keep responses brief.",
|
||||||
|
Timeout: 5 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
exec, err := NewExecutor(manager, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: Failed to create executor: %v", err)
|
||||||
|
}
|
||||||
|
defer exec.Close()
|
||||||
|
|
||||||
|
// Verify shouldSkipClaudeCLI returns false
|
||||||
|
assert.False(t, exec.shouldSkipClaudeCLI(), "Should NOT skip Claude CLI when prompts are configured")
|
||||||
|
|
||||||
|
// Execute Stream with a simple prompt
|
||||||
|
ctx := agentContext.New(context.Background(), nil, opts.ChatID)
|
||||||
|
messages := []agentContext.Message{
|
||||||
|
{Role: "user", Content: "Reply with exactly: TEST_SUCCESS"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect streaming output
|
||||||
|
var streamedContent strings.Builder
|
||||||
|
streamHandler := func(chunkType message.StreamChunkType, data []byte) int {
|
||||||
|
if chunkType == message.ChunkText {
|
||||||
|
streamedContent.Write(data)
|
||||||
|
}
|
||||||
|
return 0 // continue streaming
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Executing Claude CLI with real API call...")
|
||||||
|
startTime := time.Now()
|
||||||
|
response, err := exec.Stream(ctx, messages, streamHandler)
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
t.Logf("Execution took: %v", duration)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Stream error (might be expected if Docker/API issue): %v", err)
|
||||||
|
t.Skipf("Skipping assertion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, response, "Response should not be nil")
|
||||||
|
|
||||||
|
// Log response details
|
||||||
|
t.Logf("Response ID: %s", response.ID)
|
||||||
|
t.Logf("Response Model: %s", response.Model)
|
||||||
|
t.Logf("Response Content: %v", response.Content)
|
||||||
|
t.Logf("Streamed Content: %s", streamedContent.String())
|
||||||
|
|
||||||
|
// Verify we got some response
|
||||||
|
var fullResponse string
|
||||||
|
if content, ok := response.Content.(string); ok {
|
||||||
|
fullResponse = content
|
||||||
|
}
|
||||||
|
if fullResponse == "" {
|
||||||
|
fullResponse = streamedContent.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if fullResponse != "" {
|
||||||
|
t.Logf("✓ Claude CLI executed successfully with response: %s", truncate(fullResponse, 200))
|
||||||
|
} else {
|
||||||
|
t.Log("⚠ Empty response (Claude CLI might have issues)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestE2EBuildInputJSONLIntegration tests the full flow of building input JSONL
|
||||||
|
func TestE2EBuildInputJSONLIntegration(t *testing.T) {
|
||||||
|
// Test with conversation history
|
||||||
|
messages := []agentContext.Message{
|
||||||
|
{Role: "system", Content: "You are a helpful assistant"},
|
||||||
|
{Role: "user", Content: "What is 2+2?"},
|
||||||
|
{Role: "assistant", Content: "4"},
|
||||||
|
{Role: "user", Content: "What about 3+3?"},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonl, err := BuildInputJSONL(messages)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Logf("Input JSONL:\n%s", string(jsonl))
|
||||||
|
|
||||||
|
// Verify format
|
||||||
|
lines := strings.Split(string(jsonl), "\n")
|
||||||
|
assert.GreaterOrEqual(t, len(lines), 3, "Should have at least 3 lines (user, assistant, user)")
|
||||||
|
|
||||||
|
// System message should NOT be in JSONL
|
||||||
|
assert.NotContains(t, string(jsonl), "You are a helpful assistant", "System message should not be in JSONL")
|
||||||
|
|
||||||
|
// User and assistant messages should be present
|
||||||
|
assert.Contains(t, string(jsonl), "What is 2+2", "First user message should be present")
|
||||||
|
assert.Contains(t, string(jsonl), "4", "Assistant response should be present")
|
||||||
|
assert.Contains(t, string(jsonl), "What about 3+3", "Second user message should be present")
|
||||||
|
|
||||||
|
t.Log("✓ Input JSONL format verified")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestE2EBuildCommand tests the full command building
|
||||||
|
func TestE2EBuildCommand(t *testing.T) {
|
||||||
|
messages := []agentContext.Message{
|
||||||
|
{Role: "system", Content: "You are helpful"},
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
ConnectorHost: "https://api.example.com",
|
||||||
|
ConnectorKey: "test-key",
|
||||||
|
Model: "test-model",
|
||||||
|
Arguments: map[string]interface{}{
|
||||||
|
"permission_mode": "bypassPermissions",
|
||||||
|
},
|
||||||
|
MCPConfig: []byte(`{"mcpServers":{}}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, env, err := BuildCommand(messages, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Logf("Command: %v", cmd)
|
||||||
|
t.Logf("Environment: %v", env)
|
||||||
|
|
||||||
|
// Verify command structure
|
||||||
|
assert.Equal(t, "bash", cmd[0])
|
||||||
|
assert.Equal(t, "-c", cmd[1])
|
||||||
|
|
||||||
|
bashCmd := cmd[2]
|
||||||
|
// Should use heredoc with INPUTEOF
|
||||||
|
assert.Contains(t, bashCmd, "cat << 'INPUTEOF'", "Should use heredoc")
|
||||||
|
assert.Contains(t, bashCmd, "INPUTEOF", "Should have INPUTEOF delimiter")
|
||||||
|
|
||||||
|
// Should have streaming flags
|
||||||
|
assert.Contains(t, bashCmd, "--input-format", "Should have input-format flag")
|
||||||
|
assert.Contains(t, bashCmd, "--output-format", "Should have output-format flag")
|
||||||
|
assert.Contains(t, bashCmd, "--verbose", "Should have verbose flag")
|
||||||
|
assert.Contains(t, bashCmd, "stream-json", "Should use stream-json format")
|
||||||
|
|
||||||
|
// Should have permission flags
|
||||||
|
assert.Contains(t, bashCmd, "--dangerously-skip-permissions", "Should have skip-permissions flag")
|
||||||
|
assert.Contains(t, bashCmd, "--permission-mode", "Should have permission-mode flag")
|
||||||
|
assert.Contains(t, bashCmd, "bypassPermissions", "Should have bypassPermissions value")
|
||||||
|
|
||||||
|
// Should have MCP config
|
||||||
|
assert.Contains(t, bashCmd, "--mcp-config", "Should have mcp-config flag")
|
||||||
|
|
||||||
|
// Environment should have proxy settings
|
||||||
|
assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"])
|
||||||
|
assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"])
|
||||||
|
|
||||||
|
// System prompt should be passed via CLI argument, not environment variable
|
||||||
|
// CLAUDE_SYSTEM_PROMPT env var is NOT supported by Claude CLI
|
||||||
|
assert.Contains(t, bashCmd, "--append-system-prompt", "Should have append-system-prompt flag")
|
||||||
|
assert.Contains(t, bashCmd, "You are helpful", "System prompt should be in CLI args")
|
||||||
|
|
||||||
|
t.Log("✓ Command building verified")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to truncate strings
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
|
@ -6,10 +6,15 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
goujson "github.com/yaoapp/gou/json"
|
||||||
agentContext "github.com/yaoapp/yao/agent/context"
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
infraSandbox "github.com/yaoapp/yao/sandbox"
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||||
"github.com/yaoapp/yao/sandbox/ipc"
|
"github.com/yaoapp/yao/sandbox/ipc"
|
||||||
|
|
@ -28,9 +33,12 @@ type Options struct {
|
||||||
MCPConfig []byte
|
MCPConfig []byte
|
||||||
MCPTools map[string]*ipc.MCPTool // MCP tools to expose via IPC
|
MCPTools map[string]*ipc.MCPTool // MCP tools to expose via IPC
|
||||||
SkillsDir string
|
SkillsDir string
|
||||||
|
SystemPrompt string // System prompt from assistant prompts.yml
|
||||||
ConnectorHost string
|
ConnectorHost string
|
||||||
ConnectorKey string
|
ConnectorKey string
|
||||||
Model string
|
Model string
|
||||||
|
ConnectorOptions map[string]interface{} // Extra connector options (e.g., thinking, max_tokens)
|
||||||
|
Secrets map[string]string // Secrets to pass to container (e.g., GITHUB_TOKEN)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Executor implements the sandbox.Executor interface for Claude CLI
|
// Executor implements the sandbox.Executor interface for Claude CLI
|
||||||
|
|
@ -39,6 +47,7 @@ type Executor struct {
|
||||||
containerName string
|
containerName string
|
||||||
opts *Options
|
opts *Options
|
||||||
workDir string
|
workDir string
|
||||||
|
loadingMsgID string // Loading message ID for tool execution updates
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewExecutor creates a new Claude executor
|
// NewExecutor creates a new Claude executor
|
||||||
|
|
@ -90,12 +99,61 @@ func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, er
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLoadingMsgID sets the loading message ID for tool execution updates
|
||||||
|
func (e *Executor) SetLoadingMsgID(id string) {
|
||||||
|
e.loadingMsgID = id
|
||||||
|
}
|
||||||
|
|
||||||
// Stream runs the Claude CLI with streaming output
|
// Stream runs the Claude CLI with streaming output
|
||||||
func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) {
|
func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) {
|
||||||
stdCtx := context.Background()
|
// Create a cancellable context for this stream operation
|
||||||
if ctx != nil && ctx.Context != nil {
|
// We need to handle both:
|
||||||
stdCtx = ctx.Context
|
// 1. HTTP context cancellation (client disconnect)
|
||||||
|
// 2. InterruptController cancellation (user clicks "stop" button)
|
||||||
|
//
|
||||||
|
// Note on InterruptController:
|
||||||
|
// - ctx.Interrupt.Context() is only cancelled when InterruptForce && len(Messages) == 0
|
||||||
|
// - When user sends messages with the interrupt, the context is NOT cancelled
|
||||||
|
// - We use ctx.Interrupt.IsInterrupted() to check for any interrupt signal
|
||||||
|
stdCtx, cancelFunc := context.WithCancel(context.Background())
|
||||||
|
defer cancelFunc()
|
||||||
|
|
||||||
|
// Start a goroutine to monitor for interrupts and HTTP context cancellation
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stdCtx.Done():
|
||||||
|
// Already cancelled, exit
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// Check if there's a pending interrupt signal using Peek()
|
||||||
|
// This works even when Messages are included (which doesn't cancel the context)
|
||||||
|
if ctx != nil && ctx.Interrupt != nil {
|
||||||
|
if signal := ctx.Interrupt.Peek(); signal != nil {
|
||||||
|
cancelFunc()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Check InterruptController.IsInterrupted() (for context-cancelled interrupts)
|
||||||
|
if ctx != nil && ctx.Interrupt != nil && ctx.Interrupt.IsInterrupted() {
|
||||||
|
cancelFunc()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Check HTTP context
|
||||||
|
if ctx != nil && ctx.Context != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Context.Done():
|
||||||
|
cancelFunc()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Set MCP tools for this request (dynamic, runtime configuration)
|
// Set MCP tools for this request (dynamic, runtime configuration)
|
||||||
if len(e.opts.MCPTools) > 0 {
|
if len(e.opts.MCPTools) > 0 {
|
||||||
|
|
@ -112,6 +170,20 @@ func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Mes
|
||||||
return nil, fmt.Errorf("failed to prepare environment: %w", err)
|
return nil, fmt.Errorf("failed to prepare environment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we should skip Claude CLI execution
|
||||||
|
// Skip if no prompts, no skills, and no MCP config
|
||||||
|
if e.shouldSkipClaudeCLI() {
|
||||||
|
// Return empty response - hooks can use sandbox API to do their work
|
||||||
|
return &agentContext.CompletionResponse{
|
||||||
|
ID: fmt.Sprintf("sandbox-skip-%d", time.Now().UnixNano()),
|
||||||
|
Model: "sandbox",
|
||||||
|
Created: time.Now().Unix(),
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "",
|
||||||
|
FinishReason: agentContext.FinishReasonStop,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Build Claude CLI command using stored options
|
// Build Claude CLI command using stored options
|
||||||
cmd, env, err := BuildCommand(messages, e.opts)
|
cmd, env, err := BuildCommand(messages, e.opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -132,18 +204,70 @@ func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Mes
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to execute command: %w", err)
|
return nil, fmt.Errorf("failed to execute command: %w", err)
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
// Parse streaming output
|
// Ensure reader is closed when context is cancelled or function returns
|
||||||
return e.parseStream(reader, handler)
|
// This is important for cleanup when user clicks "stop"
|
||||||
|
done := make(chan struct{})
|
||||||
|
defer func() {
|
||||||
|
close(done)
|
||||||
|
reader.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Monitor for context cancellation and forcefully kill Claude CLI process
|
||||||
|
go func() {
|
||||||
|
// Also start a ticker to periodically check context status for debugging
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stdCtx.Done():
|
||||||
|
// First, kill the Claude CLI process inside the container
|
||||||
|
// This is important because closing the reader/connection alone may not stop the process
|
||||||
|
// Use a background context since stdCtx is already cancelled
|
||||||
|
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Kill claude process (the Claude CLI binary)
|
||||||
|
e.manager.KillProcess(killCtx, e.containerName, "claude")
|
||||||
|
|
||||||
|
// Also close the reader to unblock any pending reads
|
||||||
|
reader.Close()
|
||||||
|
return
|
||||||
|
case <-done:
|
||||||
|
// Normal completion, nothing to do
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// Periodic check - no action needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// DEBUG: Tee the reader to write raw output to a log file for debugging
|
||||||
|
debugLogPath := e.workDir + "/claude-cli-raw.log"
|
||||||
|
debugReader := e.createDebugReader(stdCtx, reader, debugLogPath)
|
||||||
|
|
||||||
|
// Parse streaming output (uses e.loadingMsgID set via SetLoadingMsgID)
|
||||||
|
return e.parseStream(ctx, debugReader, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldSkipClaudeCLI checks if Claude CLI execution should be skipped
|
||||||
|
// Skip when: no system prompt, no skills, and no MCP config
|
||||||
|
func (e *Executor) shouldSkipClaudeCLI() bool {
|
||||||
|
hasPrompts := e.opts.SystemPrompt != ""
|
||||||
|
hasSkills := e.opts.SkillsDir != ""
|
||||||
|
hasMCP := len(e.opts.MCPConfig) > 0
|
||||||
|
|
||||||
|
// If any of these are present, execute Claude CLI
|
||||||
|
return !hasPrompts && !hasSkills && !hasMCP
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepareEnvironment prepares the container environment before execution
|
// prepareEnvironment prepares the container environment before execution
|
||||||
// This includes: CCR config, MCP config, and Skills directory
|
// This includes: claude-proxy config, MCP config, and Skills directory
|
||||||
func (e *Executor) prepareEnvironment(ctx context.Context) error {
|
func (e *Executor) prepareEnvironment(ctx context.Context) error {
|
||||||
// 1. Write CCR config (Claude Code Router configuration)
|
// 1. Write claude-proxy config and start the proxy
|
||||||
if err := e.writeCCRConfig(ctx); err != nil {
|
if err := e.startClaudeProxy(ctx); err != nil {
|
||||||
return fmt.Errorf("failed to write CCR config: %w", err)
|
return fmt.Errorf("failed to start claude-proxy: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Write MCP config if provided
|
// 2. Write MCP config if provided
|
||||||
|
|
@ -165,20 +289,55 @@ func (e *Executor) prepareEnvironment(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeCCRConfig writes the CCR configuration file to the container
|
// startClaudeProxy writes proxy config and starts claude-proxy
|
||||||
func (e *Executor) writeCCRConfig(ctx context.Context) error {
|
func (e *Executor) startClaudeProxy(ctx context.Context) error {
|
||||||
// Build CCR config
|
// Skip if no connector configured (e.g., test containers without claude-proxy)
|
||||||
configJSON, err := BuildCCRConfig(e.opts)
|
if e.opts.ConnectorHost == "" || e.opts.ConnectorKey == "" {
|
||||||
if err != nil {
|
return nil
|
||||||
return fmt.Errorf("failed to build CCR config: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write config to container's CCR directory
|
// Build proxy config
|
||||||
configPath := "/home/sandbox/.claude-code-router/config.json"
|
configJSON, err := BuildProxyConfig(e.opts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to build proxy config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config directory (outside workspace for security - user can't see api_key/secrets)
|
||||||
|
// /tmp/.yao/ is not visible to user's file manager
|
||||||
|
configDir := "/tmp/.yao"
|
||||||
|
if _, err := e.manager.Exec(ctx, e.containerName, []string{"mkdir", "-p", configDir}, nil); err != nil {
|
||||||
|
return fmt.Errorf("failed to create config directory %s: %w", configDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write config to secure location (not in /workspace/)
|
||||||
|
configPath := configDir + "/proxy.json"
|
||||||
if err := e.manager.WriteFile(ctx, e.containerName, configPath, configJSON); err != nil {
|
if err := e.manager.WriteFile(ctx, e.containerName, configPath, configJSON); err != nil {
|
||||||
return fmt.Errorf("failed to write config to %s: %w", configPath, err)
|
return fmt.Errorf("failed to write config to %s: %w", configPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the proxy (only if start-claude-proxy exists in the image)
|
||||||
|
result, err := e.manager.Exec(ctx, e.containerName, []string{"which", "start-claude-proxy"}, &infraSandbox.ExecOptions{
|
||||||
|
WorkDir: e.workDir,
|
||||||
|
})
|
||||||
|
if err != nil || result.ExitCode != 0 {
|
||||||
|
// start-claude-proxy not available (e.g., alpine test image), skip
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the proxy
|
||||||
|
result, err = e.manager.Exec(ctx, e.containerName, []string{"start-claude-proxy"}, &infraSandbox.ExecOptions{
|
||||||
|
WorkDir: e.workDir,
|
||||||
|
Env: map[string]string{
|
||||||
|
"WORKSPACE": e.workDir,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start claude-proxy: %w", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("claude-proxy failed to start: %s", result.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,8 +388,62 @@ func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Me
|
||||||
return e.Stream(ctx, messages, nil)
|
return e.Stream(ctx, messages, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseStream parses Claude CLI streaming output
|
// debugWriter wraps an io.Reader to write all data to a debug log file
|
||||||
func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) {
|
type debugWriter struct {
|
||||||
|
reader io.Reader
|
||||||
|
logFile *os.File
|
||||||
|
buffer []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *debugWriter) Read(p []byte) (n int, err error) {
|
||||||
|
n, err = d.reader.Read(p)
|
||||||
|
if n > 0 && d.logFile != nil {
|
||||||
|
// Write raw bytes to log file
|
||||||
|
d.logFile.Write(p[:n])
|
||||||
|
d.logFile.Sync()
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *debugWriter) Close() error {
|
||||||
|
if d.logFile != nil {
|
||||||
|
d.logFile.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDebugReader creates a tee reader that writes to a debug log file
|
||||||
|
// The log file is written to the container's workspace for inspection
|
||||||
|
func (e *Executor) createDebugReader(ctx context.Context, reader io.ReadCloser, logPath string) io.Reader {
|
||||||
|
// Create a local temp file for debug logging
|
||||||
|
// We write to a local file first, then copy to container when done
|
||||||
|
localLogPath := "/tmp/claude-cli-debug-" + e.containerName + ".log"
|
||||||
|
logFile, err := os.Create(localLogPath)
|
||||||
|
if err != nil {
|
||||||
|
return reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write header
|
||||||
|
logFile.WriteString("=== Claude CLI Raw Output Debug Log ===\n")
|
||||||
|
logFile.WriteString(fmt.Sprintf("Container: %s\n", e.containerName))
|
||||||
|
logFile.WriteString(fmt.Sprintf("Time: %s\n", time.Now().Format(time.RFC3339)))
|
||||||
|
logFile.WriteString(fmt.Sprintf("WorkDir: %s\n", e.workDir))
|
||||||
|
logFile.WriteString("=== BEGIN OUTPUT ===\n")
|
||||||
|
logFile.Sync()
|
||||||
|
|
||||||
|
return &debugWriter{
|
||||||
|
reader: reader,
|
||||||
|
logFile: logFile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStream parses Claude CLI streaming output (stream-json format)
|
||||||
|
// Claude CLI output format with --include-partial-messages:
|
||||||
|
// - {"type":"system","subtype":"init",...} - initialization
|
||||||
|
// - {"type":"stream_event","event":{"delta":{"type":"text_delta","text":"..."}}} - real-time text deltas
|
||||||
|
// - {"type":"assistant","message":{...,"content":[{"type":"text","text":"..."}],...}} - complete messages
|
||||||
|
// - {"type":"result","subtype":"success",...,"result":"..."} - final result
|
||||||
|
func (e *Executor) parseStream(ctx *agentContext.Context, reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) {
|
||||||
scanner := bufio.NewScanner(reader)
|
scanner := bufio.NewScanner(reader)
|
||||||
// Increase buffer size for potentially large outputs
|
// Increase buffer size for potentially large outputs
|
||||||
buf := make([]byte, 0, 64*1024)
|
buf := make([]byte, 0, 64*1024)
|
||||||
|
|
@ -240,18 +453,65 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
|
||||||
var toolCalls []agentContext.ToolCall
|
var toolCalls []agentContext.ToolCall
|
||||||
var model string
|
var model string
|
||||||
var usage *message.UsageInfo
|
var usage *message.UsageInfo
|
||||||
|
var finalResult string
|
||||||
|
messageStarted := false // Track if we've sent ChunkMessageStart
|
||||||
|
prepLoadingClosed := false // Track if "preparing sandbox" loading has been closed
|
||||||
|
|
||||||
|
// Tool input accumulation state
|
||||||
|
type toolState struct {
|
||||||
|
name string
|
||||||
|
index int
|
||||||
|
inputJSON strings.Builder
|
||||||
|
loadingID string // Each tool has its own loading message
|
||||||
|
}
|
||||||
|
var currentTool *toolState
|
||||||
|
var lastToolLoadingID string // Track the last tool loading ID to close it
|
||||||
|
|
||||||
|
// Helper function to close "preparing sandbox" loading on first output
|
||||||
|
closePrepLoading := func() {
|
||||||
|
if !prepLoadingClosed && e.loadingMsgID != "" && ctx != nil {
|
||||||
|
doneMsg := &message.Message{
|
||||||
|
MessageID: e.loadingMsgID,
|
||||||
|
Delta: true,
|
||||||
|
DeltaAction: message.DeltaReplace,
|
||||||
|
Type: message.TypeLoading,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"message": "",
|
||||||
|
"done": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx.Send(doneMsg)
|
||||||
|
prepLoadingClosed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lineCount := 0
|
||||||
|
|
||||||
|
// Get the underlying context for cancellation checks
|
||||||
|
var stdCtx context.Context
|
||||||
|
if ctx != nil && ctx.Context != nil {
|
||||||
|
stdCtx = ctx.Context
|
||||||
|
} else {
|
||||||
|
stdCtx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
|
// Check for context cancellation on each iteration
|
||||||
|
select {
|
||||||
|
case <-stdCtx.Done():
|
||||||
|
return nil, stdCtx.Err()
|
||||||
|
default:
|
||||||
|
// Continue processing
|
||||||
|
}
|
||||||
|
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
|
lineCount++
|
||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Docker stream demuxing is handled by sandbox.Manager.Stream()
|
|
||||||
// which uses stdcopy.StdCopy to properly separate stdout/stderr
|
|
||||||
|
|
||||||
// Try to parse as JSON (Claude CLI --output-format stream-json)
|
// Try to parse as JSON (Claude CLI --output-format stream-json)
|
||||||
var msg StreamMessage
|
var msg map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||||||
// Not JSON, might be plain text output
|
// Not JSON, might be plain text output
|
||||||
textContent.WriteString(line)
|
textContent.WriteString(line)
|
||||||
|
|
@ -259,24 +519,248 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process different message types
|
msgType, _ := msg["type"].(string)
|
||||||
switch msg.Type {
|
|
||||||
|
// Process Claude CLI stream-json message types
|
||||||
|
switch msgType {
|
||||||
|
case "system":
|
||||||
|
// Initialization message - extract model if available
|
||||||
|
if m, ok := msg["model"].(string); ok {
|
||||||
|
model = m
|
||||||
|
}
|
||||||
|
|
||||||
|
case "stream_event":
|
||||||
|
// Real-time streaming event (from --include-partial-messages)
|
||||||
|
// Format: {"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"..."}}}
|
||||||
|
if event, ok := msg["event"].(map[string]interface{}); ok {
|
||||||
|
eventType, _ := event["type"].(string)
|
||||||
|
|
||||||
|
switch eventType {
|
||||||
|
case "content_block_start":
|
||||||
|
// Check if this is a tool_use block starting
|
||||||
|
// Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"...","name":"Write","input":{}}}}
|
||||||
|
if contentBlock, ok := event["content_block"].(map[string]interface{}); ok {
|
||||||
|
blockType, _ := contentBlock["type"].(string)
|
||||||
|
if blockType == "tool_use" {
|
||||||
|
toolName, _ := contentBlock["name"].(string)
|
||||||
|
blockIndex := 0
|
||||||
|
if idx, ok := event["index"].(float64); ok {
|
||||||
|
blockIndex = int(idx)
|
||||||
|
}
|
||||||
|
if toolName != "" && ctx != nil {
|
||||||
|
// Close "preparing sandbox" loading on first tool
|
||||||
|
closePrepLoading()
|
||||||
|
|
||||||
|
// Close previous tool loading if exists
|
||||||
|
if lastToolLoadingID != "" {
|
||||||
|
doneMsg := &message.Message{
|
||||||
|
MessageID: lastToolLoadingID,
|
||||||
|
Delta: true,
|
||||||
|
DeltaAction: message.DeltaReplace,
|
||||||
|
Type: message.TypeLoading,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"message": "",
|
||||||
|
"done": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx.Send(doneMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new loading message for this tool
|
||||||
|
locale := ctx.Locale
|
||||||
|
toolLoadingMsg := &message.Message{
|
||||||
|
Type: message.TypeLoading,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"message": getToolDescription(toolName, locale),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
newLoadingID, _ := ctx.SendStream(toolLoadingMsg)
|
||||||
|
|
||||||
|
// Initialize tool state for input accumulation
|
||||||
|
currentTool = &toolState{
|
||||||
|
name: toolName,
|
||||||
|
index: blockIndex,
|
||||||
|
loadingID: newLoadingID,
|
||||||
|
}
|
||||||
|
lastToolLoadingID = newLoadingID
|
||||||
|
|
||||||
|
log.Printf("[Sandbox] Tool started: %s", toolName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "content_block_delta":
|
case "content_block_delta":
|
||||||
// Streaming text content
|
if delta, ok := event["delta"].(map[string]interface{}); ok {
|
||||||
if delta, ok := msg.Content.(map[string]interface{}); ok {
|
deltaType, _ := delta["type"].(string)
|
||||||
if text, ok := delta["text"].(string); ok {
|
switch deltaType {
|
||||||
|
case "text_delta":
|
||||||
|
if text, ok := delta["text"].(string); ok && text != "" {
|
||||||
|
// Close "preparing sandbox" loading on first text output
|
||||||
|
closePrepLoading()
|
||||||
|
|
||||||
|
// Send to stream handler for real-time output
|
||||||
|
if handler != nil {
|
||||||
|
// Send ChunkMessageStart first if not already started
|
||||||
|
if !messageStarted {
|
||||||
|
startData := message.EventMessageStartData{
|
||||||
|
MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
|
||||||
|
Type: "text",
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
startDataJSON, _ := json.Marshal(startData)
|
||||||
|
handler(message.ChunkMessageStart, startDataJSON)
|
||||||
|
messageStarted = true
|
||||||
|
}
|
||||||
|
handler(message.ChunkText, []byte(text))
|
||||||
|
}
|
||||||
|
// Also accumulate for final response
|
||||||
|
textContent.WriteString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "input_json_delta":
|
||||||
|
// Accumulate tool input JSON fragments
|
||||||
|
if currentTool != nil {
|
||||||
|
if partialJSON, ok := delta["partial_json"].(string); ok {
|
||||||
|
currentTool.inputJSON.WriteString(partialJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "content_block_stop":
|
||||||
|
// Tool input complete - parse and update loading with detailed info
|
||||||
|
if currentTool != nil && currentTool.loadingID != "" && ctx != nil {
|
||||||
|
inputStr := currentTool.inputJSON.String()
|
||||||
|
if inputStr != "" {
|
||||||
|
// Use gou/json.Parse for fault-tolerant parsing
|
||||||
|
locale := ctx.Locale
|
||||||
|
detailedMsg := getToolDetailedDescription(currentTool.name, inputStr, locale)
|
||||||
|
if detailedMsg != "" {
|
||||||
|
toolMsg := &message.Message{
|
||||||
|
MessageID: currentTool.loadingID,
|
||||||
|
Delta: true,
|
||||||
|
DeltaAction: message.DeltaReplace,
|
||||||
|
Type: message.TypeLoading,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"message": detailedMsg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx.Send(toolMsg)
|
||||||
|
log.Printf("[Sandbox] Tool: %s -> %s", currentTool.name, detailedMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Note: Don't close loading here - it will be closed when next tool starts or at end
|
||||||
|
// Reset tool state but keep lastToolLoadingID to close it later
|
||||||
|
currentTool = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "assistant":
|
||||||
|
// Assistant message - extract content
|
||||||
|
// With --include-partial-messages, we receive real-time text via stream_event
|
||||||
|
// The assistant message contains the full accumulated content
|
||||||
|
if msgData, ok := msg["message"].(map[string]interface{}); ok {
|
||||||
|
// Get model from message
|
||||||
|
if m, ok := msgData["model"].(string); ok && model == "" {
|
||||||
|
model = m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the final message (has stop_reason)
|
||||||
|
stopReason, hasStopReason := msgData["stop_reason"].(string)
|
||||||
|
isFinalMessage := hasStopReason && stopReason != ""
|
||||||
|
|
||||||
|
// Extract content from final message
|
||||||
|
// This serves as a fallback if stream_event wasn't received
|
||||||
|
if isFinalMessage {
|
||||||
|
if contentArr, ok := msgData["content"].([]interface{}); ok {
|
||||||
|
for _, item := range contentArr {
|
||||||
|
if contentItem, ok := item.(map[string]interface{}); ok {
|
||||||
|
itemType, _ := contentItem["type"].(string)
|
||||||
|
|
||||||
|
switch itemType {
|
||||||
|
case "text":
|
||||||
|
// Only use this if we haven't already accumulated text from stream_event
|
||||||
|
if textContent.Len() == 0 {
|
||||||
|
if text, ok := contentItem["text"].(string); ok && text != "" {
|
||||||
textContent.WriteString(text)
|
textContent.WriteString(text)
|
||||||
// Send to stream handler if available
|
// Send to stream handler if available
|
||||||
if handler != nil {
|
if handler != nil {
|
||||||
|
if !messageStarted {
|
||||||
|
startData := message.EventMessageStartData{
|
||||||
|
MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
|
||||||
|
Type: "text",
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
startDataJSON, _ := json.Marshal(startData)
|
||||||
|
handler(message.ChunkMessageStart, startDataJSON)
|
||||||
|
messageStarted = true
|
||||||
|
}
|
||||||
handler(message.ChunkText, []byte(text))
|
handler(message.ChunkText, []byte(text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "message_delta":
|
case "tool_use":
|
||||||
// Message completion with usage
|
toolName := getString(contentItem, "name")
|
||||||
if content, ok := msg.Content.(map[string]interface{}); ok {
|
toolCall := agentContext.ToolCall{
|
||||||
if usageData, ok := content["usage"].(map[string]interface{}); ok {
|
ID: getString(contentItem, "id"),
|
||||||
|
Type: agentContext.ToolTypeFunction,
|
||||||
|
Function: agentContext.Function{
|
||||||
|
Name: toolName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Get input as JSON string
|
||||||
|
var inputJSONStr string
|
||||||
|
if input, ok := contentItem["input"]; ok {
|
||||||
|
if inputJSON, err := json.Marshal(input); err == nil {
|
||||||
|
inputJSONStr = string(inputJSON)
|
||||||
|
toolCall.Function.Arguments = inputJSONStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolCalls = append(toolCalls, toolCall)
|
||||||
|
|
||||||
|
// Create tool loading message (from complete assistant message)
|
||||||
|
// This is a fallback for when stream_event wasn't received
|
||||||
|
if toolName != "" && ctx != nil {
|
||||||
|
// Close previous tool loading if exists
|
||||||
|
if lastToolLoadingID != "" {
|
||||||
|
doneMsg := &message.Message{
|
||||||
|
MessageID: lastToolLoadingID,
|
||||||
|
Delta: true,
|
||||||
|
DeltaAction: message.DeltaReplace,
|
||||||
|
Type: message.TypeLoading,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"message": "",
|
||||||
|
"done": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx.Send(doneMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new loading for this tool
|
||||||
|
locale := ctx.Locale
|
||||||
|
detailedMsg := getToolDetailedDescription(toolName, inputJSONStr, locale)
|
||||||
|
if detailedMsg != "" {
|
||||||
|
toolLoadingMsg := &message.Message{
|
||||||
|
Type: message.TypeLoading,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"message": detailedMsg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
newLoadingID, _ := ctx.SendStream(toolLoadingMsg)
|
||||||
|
lastToolLoadingID = newLoadingID
|
||||||
|
log.Printf("[Sandbox] Tool: %s -> %s", toolName, detailedMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract usage (from any message that has it)
|
||||||
|
if usageData, ok := msgData["usage"].(map[string]interface{}); ok {
|
||||||
usage = &message.UsageInfo{}
|
usage = &message.UsageInfo{}
|
||||||
if v, ok := usageData["input_tokens"].(float64); ok {
|
if v, ok := usageData["input_tokens"].(float64); ok {
|
||||||
usage.PromptTokens = int(v)
|
usage.PromptTokens = int(v)
|
||||||
|
|
@ -288,32 +772,32 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "message_start":
|
case "result":
|
||||||
// Extract model from message_start
|
// Final result message
|
||||||
if content, ok := msg.Content.(map[string]interface{}); ok {
|
// Check if this is an error result (is_error: true)
|
||||||
if m, ok := content["model"].(string); ok {
|
isError, _ := msg["is_error"].(bool)
|
||||||
model = m
|
if result, ok := msg["result"].(string); ok {
|
||||||
|
if isError {
|
||||||
|
// This is an error - return it as an error
|
||||||
|
return nil, fmt.Errorf("Claude CLI error: %s", result)
|
||||||
}
|
}
|
||||||
|
finalResult = result
|
||||||
}
|
}
|
||||||
|
// Send done signal to handler (only if message was started and not an error)
|
||||||
case "content_block_start":
|
if handler != nil && messageStarted && !isError {
|
||||||
// Might contain tool use blocks
|
handler(message.ChunkMessageEnd, nil)
|
||||||
if block, ok := msg.Content.(map[string]interface{}); ok {
|
|
||||||
if block["type"] == "tool_use" {
|
|
||||||
toolCall := agentContext.ToolCall{
|
|
||||||
ID: getString(block, "id"),
|
|
||||||
Type: agentContext.ToolTypeFunction,
|
|
||||||
Function: agentContext.Function{
|
|
||||||
Name: getString(block, "name"),
|
|
||||||
Arguments: "{}",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
toolCalls = append(toolCalls, toolCall)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
return nil, fmt.Errorf("Claude CLI error: %s", msg.Error)
|
// Error message
|
||||||
|
if errMsg, ok := msg["error"].(string); ok {
|
||||||
|
return nil, fmt.Errorf("Claude CLI error: %s", errMsg)
|
||||||
|
}
|
||||||
|
if errObj, ok := msg["error"].(map[string]interface{}); ok {
|
||||||
|
if errMsg, ok := errObj["message"].(string); ok {
|
||||||
|
return nil, fmt.Errorf("Claude CLI error: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -321,13 +805,34 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
|
||||||
return nil, fmt.Errorf("error reading stream: %w", err)
|
return nil, fmt.Errorf("error reading stream: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close the last tool loading message if exists
|
||||||
|
if lastToolLoadingID != "" && ctx != nil {
|
||||||
|
doneMsg := &message.Message{
|
||||||
|
MessageID: lastToolLoadingID,
|
||||||
|
Delta: true,
|
||||||
|
DeltaAction: message.DeltaReplace,
|
||||||
|
Type: message.TypeLoading,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"message": "",
|
||||||
|
"done": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx.Send(doneMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use final result if available, otherwise use accumulated text content
|
||||||
|
content := textContent.String()
|
||||||
|
if finalResult != "" && content == "" {
|
||||||
|
content = finalResult
|
||||||
|
}
|
||||||
|
|
||||||
// Build response
|
// Build response
|
||||||
response := &agentContext.CompletionResponse{
|
response := &agentContext.CompletionResponse{
|
||||||
ID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
|
ID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
|
||||||
Model: model,
|
Model: model,
|
||||||
Created: time.Now().Unix(),
|
Created: time.Now().Unix(),
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: textContent.String(),
|
Content: content,
|
||||||
FinishReason: agentContext.FinishReasonStop,
|
FinishReason: agentContext.FinishReasonStop,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -345,6 +850,161 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// truncateStr truncates a string to maxLen characters
|
||||||
|
func truncateStr(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// getToolDescription returns a human-readable, localized description for a Claude CLI tool
|
||||||
|
func getToolDescription(toolName string, locale string) string {
|
||||||
|
// Map tool names to i18n keys
|
||||||
|
toolKeys := map[string]string{
|
||||||
|
"Read": "sandbox.tool.read",
|
||||||
|
"Write": "sandbox.tool.write",
|
||||||
|
"Edit": "sandbox.tool.edit",
|
||||||
|
"StrReplace": "sandbox.tool.edit",
|
||||||
|
"Bash": "sandbox.tool.bash",
|
||||||
|
"Shell": "sandbox.tool.bash",
|
||||||
|
"Glob": "sandbox.tool.glob",
|
||||||
|
"Grep": "sandbox.tool.grep",
|
||||||
|
"LS": "sandbox.tool.ls",
|
||||||
|
"Task": "sandbox.tool.task",
|
||||||
|
"WebSearch": "sandbox.tool.web_search",
|
||||||
|
"WebFetch": "sandbox.tool.web_fetch",
|
||||||
|
"TodoWrite": "sandbox.tool.todo_write",
|
||||||
|
"AskQuestion": "sandbox.tool.ask_question",
|
||||||
|
"SwitchMode": "sandbox.tool.switch_mode",
|
||||||
|
"ReadLints": "sandbox.tool.read_lints",
|
||||||
|
"EditNotebook": "sandbox.tool.edit_notebook",
|
||||||
|
}
|
||||||
|
|
||||||
|
if key, ok := toolKeys[toolName]; ok {
|
||||||
|
return i18n.T(locale, key)
|
||||||
|
}
|
||||||
|
// For unknown tools, use the unknown key and replace {{name}} manually
|
||||||
|
template := i18n.T(locale, "sandbox.tool.unknown")
|
||||||
|
return strings.Replace(template, "{{name}}", toolName, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getToolDetailedDescription returns a detailed description with specific parameters
|
||||||
|
// It parses the tool input JSON and extracts key information to show users
|
||||||
|
func getToolDetailedDescription(toolName string, inputJSON string, locale string) string {
|
||||||
|
// Parse the input JSON using fault-tolerant parser
|
||||||
|
parsed, err := goujson.Parse(inputJSON)
|
||||||
|
if err != nil {
|
||||||
|
// Fall back to basic description if parsing fails
|
||||||
|
return getToolDescription(toolName, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
input, ok := parsed.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return getToolDescription(toolName, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key information based on tool type
|
||||||
|
var detail string
|
||||||
|
switch toolName {
|
||||||
|
case "Bash", "Shell":
|
||||||
|
// Show the command being executed
|
||||||
|
if cmd, ok := input["command"].(string); ok && cmd != "" {
|
||||||
|
// Truncate long commands
|
||||||
|
if len(cmd) > 50 {
|
||||||
|
cmd = cmd[:47] + "..."
|
||||||
|
}
|
||||||
|
detail = cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
case "Read":
|
||||||
|
// Show the file being read
|
||||||
|
if path, ok := input["path"].(string); ok && path != "" {
|
||||||
|
detail = filepath.Base(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "Write":
|
||||||
|
// Show the file being written
|
||||||
|
// Note: Claude CLI uses "file_path" for Write tool, not "path"
|
||||||
|
if path, ok := input["file_path"].(string); ok && path != "" {
|
||||||
|
detail = filepath.Base(path)
|
||||||
|
} else if path, ok := input["path"].(string); ok && path != "" {
|
||||||
|
detail = filepath.Base(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "Edit", "StrReplace":
|
||||||
|
// Show the file being edited
|
||||||
|
if path, ok := input["path"].(string); ok && path != "" {
|
||||||
|
detail = filepath.Base(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "Glob":
|
||||||
|
// Show the glob pattern
|
||||||
|
if pattern, ok := input["glob_pattern"].(string); ok && pattern != "" {
|
||||||
|
detail = pattern
|
||||||
|
} else if pattern, ok := input["pattern"].(string); ok && pattern != "" {
|
||||||
|
detail = pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
case "Grep":
|
||||||
|
// Show the search pattern
|
||||||
|
if pattern, ok := input["pattern"].(string); ok && pattern != "" {
|
||||||
|
if len(pattern) > 30 {
|
||||||
|
pattern = pattern[:27] + "..."
|
||||||
|
}
|
||||||
|
detail = pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
case "LS":
|
||||||
|
// Show the directory
|
||||||
|
if path, ok := input["target_directory"].(string); ok && path != "" {
|
||||||
|
detail = filepath.Base(path)
|
||||||
|
} else if path, ok := input["path"].(string); ok && path != "" {
|
||||||
|
detail = filepath.Base(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "WebSearch":
|
||||||
|
// Show the search query
|
||||||
|
if query, ok := input["search_term"].(string); ok && query != "" {
|
||||||
|
if len(query) > 40 {
|
||||||
|
query = query[:37] + "..."
|
||||||
|
}
|
||||||
|
detail = query
|
||||||
|
} else if query, ok := input["query"].(string); ok && query != "" {
|
||||||
|
if len(query) > 40 {
|
||||||
|
query = query[:37] + "..."
|
||||||
|
}
|
||||||
|
detail = query
|
||||||
|
}
|
||||||
|
|
||||||
|
case "WebFetch":
|
||||||
|
// Show the URL
|
||||||
|
if url, ok := input["url"].(string); ok && url != "" {
|
||||||
|
// Extract domain from URL
|
||||||
|
if len(url) > 50 {
|
||||||
|
url = url[:47] + "..."
|
||||||
|
}
|
||||||
|
detail = url
|
||||||
|
}
|
||||||
|
|
||||||
|
case "Task":
|
||||||
|
// Show the task description
|
||||||
|
if desc, ok := input["description"].(string); ok && desc != "" {
|
||||||
|
if len(desc) > 40 {
|
||||||
|
desc = desc[:37] + "..."
|
||||||
|
}
|
||||||
|
detail = desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the message with detail
|
||||||
|
baseMsg := getToolDescription(toolName, locale)
|
||||||
|
if detail != "" {
|
||||||
|
return baseMsg + ": " + detail
|
||||||
|
}
|
||||||
|
return baseMsg
|
||||||
|
}
|
||||||
|
|
||||||
// ReadFile reads a file from the container
|
// ReadFile reads a file from the container
|
||||||
func (e *Executor) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
func (e *Executor) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
||||||
// Make path absolute if not
|
// Make path absolute if not
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,7 @@ func TestClaudeExecutorMCPConfigWrite(t *testing.T) {
|
||||||
UserID: "test-user",
|
UserID: "test-user",
|
||||||
ChatID: fmt.Sprintf("test-chat-mcp-write-%d", time.Now().UnixNano()),
|
ChatID: fmt.Sprintf("test-chat-mcp-write-%d", time.Now().UnixNano()),
|
||||||
MCPConfig: mcpConfig,
|
MCPConfig: mcpConfig,
|
||||||
|
// No connector config - skip proxy start for alpine test image
|
||||||
}
|
}
|
||||||
|
|
||||||
exec, err := NewExecutor(manager, opts)
|
exec, err := NewExecutor(manager, opts)
|
||||||
|
|
@ -245,6 +246,7 @@ func TestClaudeExecutorSkillsCopy(t *testing.T) {
|
||||||
UserID: "test-user",
|
UserID: "test-user",
|
||||||
ChatID: fmt.Sprintf("test-chat-skills-%d", time.Now().UnixNano()),
|
ChatID: fmt.Sprintf("test-chat-skills-%d", time.Now().UnixNano()),
|
||||||
SkillsDir: skillsDir,
|
SkillsDir: skillsDir,
|
||||||
|
// No connector config - skip proxy start for alpine test image
|
||||||
}
|
}
|
||||||
|
|
||||||
exec, err := NewExecutor(manager, opts)
|
exec, err := NewExecutor(manager, opts)
|
||||||
|
|
@ -316,11 +318,9 @@ func TestClaudeExecutorPrepareEnvironmentIntegration(t *testing.T) {
|
||||||
Image: "alpine:latest",
|
Image: "alpine:latest",
|
||||||
UserID: "test-user",
|
UserID: "test-user",
|
||||||
ChatID: fmt.Sprintf("test-chat-full-env-%d", time.Now().UnixNano()),
|
ChatID: fmt.Sprintf("test-chat-full-env-%d", time.Now().UnixNano()),
|
||||||
ConnectorHost: "https://api.test.com",
|
|
||||||
ConnectorKey: "test-key",
|
|
||||||
Model: "test-model",
|
|
||||||
MCPConfig: mcpConfig,
|
MCPConfig: mcpConfig,
|
||||||
SkillsDir: skillsDir,
|
SkillsDir: skillsDir,
|
||||||
|
// No connector config - skip proxy start for alpine test image
|
||||||
}
|
}
|
||||||
|
|
||||||
exec, err := NewExecutor(manager, opts)
|
exec, err := NewExecutor(manager, opts)
|
||||||
|
|
@ -333,26 +333,19 @@ func TestClaudeExecutorPrepareEnvironmentIntegration(t *testing.T) {
|
||||||
err = exec.prepareEnvironment(ctx)
|
err = exec.prepareEnvironment(ctx)
|
||||||
require.NoError(t, err, "prepareEnvironment should succeed")
|
require.NoError(t, err, "prepareEnvironment should succeed")
|
||||||
|
|
||||||
// Verify all files exist
|
// 1. Check MCP config
|
||||||
// 1. Check CCR config
|
|
||||||
ccrContent, err := exec.Exec(ctx, []string{"cat", "/home/sandbox/.claude-code-router/config.json"})
|
|
||||||
require.NoError(t, err, "CCR config should exist")
|
|
||||||
assert.Contains(t, ccrContent, "api_base_url", "CCR config should contain api_base_url")
|
|
||||||
t.Logf("✓ CCR config verified: %d bytes", len(ccrContent))
|
|
||||||
|
|
||||||
// 2. Check MCP config
|
|
||||||
mcpContent, err := exec.ReadFile(ctx, ".mcp.json")
|
mcpContent, err := exec.ReadFile(ctx, ".mcp.json")
|
||||||
require.NoError(t, err, "MCP config should exist in container")
|
require.NoError(t, err, "MCP config should exist in container")
|
||||||
assert.JSONEq(t, string(mcpConfig), string(mcpContent), "MCP config content should match")
|
assert.JSONEq(t, string(mcpConfig), string(mcpContent), "MCP config content should match")
|
||||||
t.Logf("✓ MCP config verified: %s", string(mcpContent))
|
t.Logf("✓ MCP config verified: %s", string(mcpContent))
|
||||||
|
|
||||||
// 3. Check Skills directory structure
|
// 2. Check Skills directory structure
|
||||||
output, err := exec.Exec(ctx, []string{"ls", "-la", ".claude/skills"})
|
output, err := exec.Exec(ctx, []string{"ls", "-la", ".claude/skills"})
|
||||||
require.NoError(t, err, "Skills directory should exist in container")
|
require.NoError(t, err, "Skills directory should exist in container")
|
||||||
assert.Contains(t, output, "echo-test", "echo-test skill should exist")
|
assert.Contains(t, output, "echo-test", "echo-test skill should exist")
|
||||||
t.Logf("✓ Skills directory contents:\n%s", output)
|
t.Logf("✓ Skills directory contents:\n%s", output)
|
||||||
|
|
||||||
// 4. Check skill content
|
// 3. Check skill content
|
||||||
skillContent, err := exec.ReadFile(ctx, ".claude/skills/echo-test/SKILL.md")
|
skillContent, err := exec.ReadFile(ctx, ".claude/skills/echo-test/SKILL.md")
|
||||||
require.NoError(t, err, "SKILL.md should exist in container")
|
require.NoError(t, err, "SKILL.md should exist in container")
|
||||||
require.NotEmpty(t, skillContent, "SKILL.md should not be empty")
|
require.NotEmpty(t, skillContent, "SKILL.md should not be empty")
|
||||||
|
|
|
||||||
310
agent/sandbox/claude/real_e2e_test.go
Normal file
310
agent/sandbox/claude/real_e2e_test.go
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRealClaudeCLIExecution tests real Claude CLI execution with streaming
|
||||||
|
// This test requires:
|
||||||
|
// 1. Docker running with yaoapp/sandbox-claude:latest image
|
||||||
|
// 2. Environment variables: DEEPSEEK_API_KEY, DEEPSEEK_API_PROXY, DEEPSEEK_MODELS_V3
|
||||||
|
func TestRealClaudeCLIExecution(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping real E2E test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for required environment variables
|
||||||
|
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||||
|
apiProxy := os.Getenv("DEEPSEEK_API_PROXY")
|
||||||
|
model := os.Getenv("DEEPSEEK_MODELS_V3")
|
||||||
|
|
||||||
|
if apiKey == "" || apiProxy == "" || model == "" {
|
||||||
|
t.Skip("Skipping test: DEEPSEEK_API_KEY, DEEPSEEK_API_PROXY, or DEEPSEEK_MODELS_V3 not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Get data root from environment
|
||||||
|
dataRoot := os.Getenv("YAO_ROOT")
|
||||||
|
if dataRoot == "" {
|
||||||
|
t.Skip("Skipping test: YAO_ROOT not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config with proper paths
|
||||||
|
cfg := infraSandbox.DefaultConfig()
|
||||||
|
cfg.Init(dataRoot)
|
||||||
|
|
||||||
|
manager, err := infraSandbox.NewManager(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: Docker not available: %v", err)
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
// Create options WITH SystemPrompt (triggers Claude CLI execution)
|
||||||
|
opts := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "yaoapp/sandbox-claude:latest",
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: fmt.Sprintf("test-real-e2e-%d", time.Now().UnixNano()),
|
||||||
|
ConnectorHost: apiProxy,
|
||||||
|
ConnectorKey: apiKey,
|
||||||
|
Model: model,
|
||||||
|
SystemPrompt: "You are a helpful assistant. Reply concisely.",
|
||||||
|
Timeout: 3 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Creating executor with options:")
|
||||||
|
t.Logf(" ConnectorHost: %s", opts.ConnectorHost)
|
||||||
|
t.Logf(" Model: %s", opts.Model)
|
||||||
|
t.Logf(" SystemPrompt: %s", opts.SystemPrompt)
|
||||||
|
|
||||||
|
exec, err := NewExecutor(manager, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: Failed to create executor: %v", err)
|
||||||
|
}
|
||||||
|
defer exec.Close()
|
||||||
|
|
||||||
|
// Verify shouldSkipClaudeCLI returns false
|
||||||
|
if exec.shouldSkipClaudeCLI() {
|
||||||
|
t.Fatal("shouldSkipClaudeCLI should return false when SystemPrompt is set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 1: First, manually test claude-proxy
|
||||||
|
t.Log("=== Test 1: Verify claude-proxy is working ===")
|
||||||
|
stdCtx := context.Background()
|
||||||
|
|
||||||
|
// Prepare environment (this starts claude-proxy)
|
||||||
|
err = exec.prepareEnvironment(stdCtx)
|
||||||
|
require.NoError(t, err, "prepareEnvironment should succeed")
|
||||||
|
|
||||||
|
// Check proxy is running
|
||||||
|
result, err := exec.manager.Exec(stdCtx, exec.containerName, []string{"pgrep", "-f", "claude-proxy"}, nil)
|
||||||
|
if err != nil || result.ExitCode != 0 {
|
||||||
|
t.Log("claude-proxy not running, checking why...")
|
||||||
|
// Check proxy log
|
||||||
|
logContent, _ := exec.ReadFile(stdCtx, "proxy.log")
|
||||||
|
t.Logf("Proxy log: %s", string(logContent))
|
||||||
|
|
||||||
|
// Check config
|
||||||
|
configContent, _ := exec.ReadFile(stdCtx, ".claude-proxy.json")
|
||||||
|
t.Logf("Proxy config: %s", string(configContent))
|
||||||
|
} else {
|
||||||
|
t.Logf("claude-proxy is running with PID: %s", strings.TrimSpace(result.Stdout))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Test simple command execution
|
||||||
|
t.Log("=== Test 2: Simple command execution ===")
|
||||||
|
ctx := agentContext.New(stdCtx, nil, opts.ChatID)
|
||||||
|
messages := []agentContext.Message{
|
||||||
|
{Role: "user", Content: "Reply with exactly: HELLO_TEST_SUCCESS"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect streaming output
|
||||||
|
var streamedChunks []string
|
||||||
|
var streamedContent strings.Builder
|
||||||
|
streamHandler := func(chunkType message.StreamChunkType, data []byte) int {
|
||||||
|
chunk := string(data)
|
||||||
|
streamedChunks = append(streamedChunks, chunk)
|
||||||
|
streamedContent.Write(data)
|
||||||
|
t.Logf("Stream chunk [%s]: %q", chunkType, chunk)
|
||||||
|
return 0 // continue streaming
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Executing Claude CLI...")
|
||||||
|
startTime := time.Now()
|
||||||
|
response, err := exec.Stream(ctx, messages, streamHandler)
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
t.Logf("Execution took: %v", duration)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Stream error: %v", err)
|
||||||
|
|
||||||
|
// Debug: check what's in the container
|
||||||
|
t.Log("=== Debug info ===")
|
||||||
|
|
||||||
|
// Check proxy log
|
||||||
|
logContent, _ := exec.ReadFile(stdCtx, "proxy.log")
|
||||||
|
t.Logf("Proxy log:\n%s", string(logContent))
|
||||||
|
|
||||||
|
// List workspace
|
||||||
|
output, _ := exec.Exec(stdCtx, []string{"ls", "-la", "/workspace"})
|
||||||
|
t.Logf("Workspace contents:\n%s", output)
|
||||||
|
|
||||||
|
// Check environment
|
||||||
|
output, _ = exec.Exec(stdCtx, []string{"env"})
|
||||||
|
t.Logf("Environment:\n%s", output)
|
||||||
|
|
||||||
|
t.Fatalf("Stream failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, response, "Response should not be nil")
|
||||||
|
|
||||||
|
// Log results
|
||||||
|
t.Logf("=== Results ===")
|
||||||
|
t.Logf("Response ID: %s", response.ID)
|
||||||
|
t.Logf("Response Model: %s", response.Model)
|
||||||
|
t.Logf("Response Content: %v", response.Content)
|
||||||
|
t.Logf("Streamed chunks count: %d", len(streamedChunks))
|
||||||
|
t.Logf("Total streamed content: %s", streamedContent.String())
|
||||||
|
|
||||||
|
// Verify we got some response
|
||||||
|
var fullResponse string
|
||||||
|
if content, ok := response.Content.(string); ok {
|
||||||
|
fullResponse = content
|
||||||
|
}
|
||||||
|
if fullResponse == "" {
|
||||||
|
fullResponse = streamedContent.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if fullResponse == "" {
|
||||||
|
// Check proxy log for errors
|
||||||
|
logContent, _ := exec.ReadFile(stdCtx, "proxy.log")
|
||||||
|
t.Logf("Proxy log (for debugging):\n%s", string(logContent))
|
||||||
|
t.Fatal("Got empty response from Claude CLI")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Successfully got response: %s", fullResponse)
|
||||||
|
|
||||||
|
// Check if streaming worked
|
||||||
|
if len(streamedChunks) > 0 {
|
||||||
|
t.Logf("✓ Streaming worked with %d chunks", len(streamedChunks))
|
||||||
|
} else {
|
||||||
|
t.Log("⚠ No streaming chunks received (might be buffered)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClaudeCLIDirectExecution tests running claude directly in the container
|
||||||
|
func TestClaudeCLIDirectExecution(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping real E2E test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for required environment variables
|
||||||
|
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||||
|
apiProxy := os.Getenv("DEEPSEEK_API_PROXY")
|
||||||
|
model := os.Getenv("DEEPSEEK_MODELS_V3")
|
||||||
|
|
||||||
|
if apiKey == "" || apiProxy == "" || model == "" {
|
||||||
|
t.Skip("Skipping test: DEEPSEEK_API_KEY, DEEPSEEK_API_PROXY, or DEEPSEEK_MODELS_V3 not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
dataRoot := os.Getenv("YAO_ROOT")
|
||||||
|
if dataRoot == "" {
|
||||||
|
t.Skip("Skipping test: YAO_ROOT not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := infraSandbox.DefaultConfig()
|
||||||
|
cfg.Init(dataRoot)
|
||||||
|
|
||||||
|
manager, err := infraSandbox.NewManager(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: Docker not available: %v", err)
|
||||||
|
}
|
||||||
|
defer manager.Close()
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "yaoapp/sandbox-claude:latest",
|
||||||
|
UserID: "test-user",
|
||||||
|
ChatID: fmt.Sprintf("test-direct-%d", time.Now().UnixNano()),
|
||||||
|
ConnectorHost: apiProxy,
|
||||||
|
ConnectorKey: apiKey,
|
||||||
|
Model: model,
|
||||||
|
Timeout: 3 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
exec, err := NewExecutor(manager, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: Failed to create executor: %v", err)
|
||||||
|
}
|
||||||
|
defer exec.Close()
|
||||||
|
|
||||||
|
stdCtx := context.Background()
|
||||||
|
|
||||||
|
// Step 1: Write proxy config and start proxy
|
||||||
|
t.Log("=== Step 1: Start claude-proxy ===")
|
||||||
|
err = exec.prepareEnvironment(stdCtx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for proxy to start
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// Check proxy status
|
||||||
|
result, err := exec.manager.Exec(stdCtx, exec.containerName, []string{"pgrep", "-f", "claude-proxy"}, nil)
|
||||||
|
if err == nil && result.ExitCode == 0 {
|
||||||
|
t.Logf("✓ claude-proxy running, PID: %s", strings.TrimSpace(result.Stdout))
|
||||||
|
} else {
|
||||||
|
t.Log("⚠ claude-proxy might not be running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Run claude CLI directly with simple prompt
|
||||||
|
t.Log("=== Step 2: Run claude CLI directly ===")
|
||||||
|
|
||||||
|
// Build a simple command - pass env vars explicitly
|
||||||
|
directCmd := []string{
|
||||||
|
"bash", "-c",
|
||||||
|
`echo '{"type":"user","message":{"role":"user","content":"say hello"}}' | claude -p --dangerously-skip-permissions --permission-mode bypassPermissions --input-format stream-json --output-format stream-json --verbose 2>&1`,
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := exec.manager.Stream(stdCtx, exec.containerName, directCmd, &infraSandbox.ExecOptions{
|
||||||
|
WorkDir: exec.workDir,
|
||||||
|
Timeout: 2 * time.Minute,
|
||||||
|
Env: map[string]string{
|
||||||
|
"ANTHROPIC_BASE_URL": "http://127.0.0.1:3456",
|
||||||
|
"ANTHROPIC_API_KEY": "dummy",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to execute: %v", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Read output
|
||||||
|
buf := make([]byte, 64*1024)
|
||||||
|
var output strings.Builder
|
||||||
|
for {
|
||||||
|
n, err := reader.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
chunk := string(buf[:n])
|
||||||
|
output.WriteString(chunk)
|
||||||
|
t.Logf("Output chunk: %q", chunk)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("=== Full output ===\n%s", output.String())
|
||||||
|
|
||||||
|
if output.Len() == 0 {
|
||||||
|
// Check logs
|
||||||
|
logContent, _ := exec.ReadFile(stdCtx, "proxy.log")
|
||||||
|
t.Logf("Proxy log:\n%s", string(logContent))
|
||||||
|
t.Fatal("Got no output from claude CLI")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for success indicators
|
||||||
|
outputStr := output.String()
|
||||||
|
if strings.Contains(outputStr, "error") || strings.Contains(outputStr, "Error") {
|
||||||
|
t.Logf("⚠ Output contains error")
|
||||||
|
}
|
||||||
|
if strings.Contains(outputStr, "content_block") || strings.Contains(outputStr, "message_start") {
|
||||||
|
t.Log("✓ Got streaming JSON output from Claude CLI")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,11 +35,14 @@ func New(manager *infraSandbox.Manager, opts *Options) (Executor, error) {
|
||||||
UserID: opts.UserID,
|
UserID: opts.UserID,
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
MCPConfig: opts.MCPConfig,
|
MCPConfig: opts.MCPConfig,
|
||||||
MCPTools: opts.MCPTools, // MCP tools to expose via IPC
|
MCPTools: opts.MCPTools,
|
||||||
SkillsDir: opts.SkillsDir,
|
SkillsDir: opts.SkillsDir,
|
||||||
|
SystemPrompt: opts.SystemPrompt, // Required for Claude CLI execution
|
||||||
ConnectorHost: opts.ConnectorHost,
|
ConnectorHost: opts.ConnectorHost,
|
||||||
ConnectorKey: opts.ConnectorKey,
|
ConnectorKey: opts.ConnectorKey,
|
||||||
Model: opts.Model,
|
Model: opts.Model,
|
||||||
|
ConnectorOptions: opts.ConnectorOptions, // Extra options like thinking, max_tokens
|
||||||
|
Secrets: opts.Secrets, // Secrets for container env vars
|
||||||
}
|
}
|
||||||
return claude.NewExecutor(manager, claudeOpts)
|
return claude.NewExecutor(manager, claudeOpts)
|
||||||
case "cursor":
|
case "cursor":
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ type Executor interface {
|
||||||
// Stream runs the request with streaming output (uses options set at creation time)
|
// Stream runs the request with streaming output (uses options set at creation time)
|
||||||
Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error)
|
Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error)
|
||||||
|
|
||||||
|
// SetLoadingMsgID sets the loading message ID for tool execution status updates
|
||||||
|
SetLoadingMsgID(id string)
|
||||||
|
|
||||||
// Filesystem operations (for Hooks)
|
// Filesystem operations (for Hooks)
|
||||||
ReadFile(ctx context.Context, path string) ([]byte, error)
|
ReadFile(ctx context.Context, path string) ([]byte, error)
|
||||||
WriteFile(ctx context.Context, path string, content []byte) error
|
WriteFile(ctx context.Context, path string, content []byte) error
|
||||||
|
|
@ -74,11 +77,23 @@ type Options struct {
|
||||||
// Skills directory - auto-resolved to assistants/{name}/skills/
|
// Skills directory - auto-resolved to assistants/{name}/skills/
|
||||||
SkillsDir string `json:"-"`
|
SkillsDir string `json:"-"`
|
||||||
|
|
||||||
|
// SystemPrompt - extracted from assistant prompts.yml
|
||||||
|
// Used to determine if Claude CLI should be called
|
||||||
|
SystemPrompt string `json:"-"`
|
||||||
|
|
||||||
// Connector settings - auto-resolved from connector config file
|
// Connector settings - auto-resolved from connector config file
|
||||||
// e.g., connectors/deepseek/v3.conn.yao → host, key, model
|
// e.g., connectors/deepseek/v3.conn.yao → host, key, model
|
||||||
ConnectorHost string `json:"-"`
|
ConnectorHost string `json:"-"`
|
||||||
ConnectorKey string `json:"-"`
|
ConnectorKey string `json:"-"`
|
||||||
Model string `json:"-"`
|
Model string `json:"-"`
|
||||||
|
|
||||||
|
// ConnectorOptions - extra options from connector config (e.g., thinking, max_tokens, temperature)
|
||||||
|
// These are backend-specific parameters passed to the proxy
|
||||||
|
ConnectorOptions map[string]interface{} `json:"-"`
|
||||||
|
|
||||||
|
// Secrets - sensitive values from sandbox.secrets config (e.g., GITHUB_TOKEN)
|
||||||
|
// Resolved from $ENV.XXX references, exported as env vars in container
|
||||||
|
Secrets map[string]string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SandboxConfig represents the sandbox configuration in assistant package.yao
|
// SandboxConfig represents the sandbox configuration in assistant package.yao
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,7 @@ type Sandbox struct {
|
||||||
MaxCPU float64 `json:"max_cpu,omitempty"` // CPU limit (e.g., 2.0)
|
MaxCPU float64 `json:"max_cpu,omitempty"` // CPU limit (e.g., 2.0)
|
||||||
Timeout string `json:"timeout,omitempty"` // Execution timeout (e.g., "10m")
|
Timeout string `json:"timeout,omitempty"` // Execution timeout (e.g., "10m")
|
||||||
Arguments map[string]interface{} `json:"arguments,omitempty"` // Command-specific arguments
|
Arguments map[string]interface{} `json:"arguments,omitempty"` // Command-specific arguments
|
||||||
|
Secrets map[string]string `json:"secrets,omitempty"` // Secrets to pass to container (e.g., GITHUB_TOKEN: "$ENV.GITHUB_TOKEN")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool represents a tool configuration for storage
|
// Tool represents a tool configuration for storage
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,21 @@ echo "Built: yao-bridge-amd64, yao-bridge-arm64"
|
||||||
|
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Build claude-proxy for both architectures
|
||||||
|
echo ""
|
||||||
|
echo "=== Building claude-proxy (multi-arch) ==="
|
||||||
|
cd "$SCRIPT_DIR/../proxy/cmd/claude-proxy"
|
||||||
|
|
||||||
|
echo "Building for linux/amd64..."
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/claude-proxy-amd64" .
|
||||||
|
|
||||||
|
echo "Building for linux/arm64..."
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/claude-proxy-arm64" .
|
||||||
|
|
||||||
|
echo "Built: claude-proxy-amd64, claude-proxy-arm64"
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
# Check if buildx is available and set up
|
# Check if buildx is available and set up
|
||||||
setup_buildx() {
|
setup_buildx() {
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -141,4 +156,5 @@ docker images | grep -E "(sandbox-base|sandbox-claude|sandbox-cursor)" | head -1
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Cleanup ==="
|
echo "=== Cleanup ==="
|
||||||
rm -f "$SCRIPT_DIR/yao-bridge-amd64" "$SCRIPT_DIR/yao-bridge-arm64"
|
rm -f "$SCRIPT_DIR/yao-bridge-amd64" "$SCRIPT_DIR/yao-bridge-arm64"
|
||||||
|
rm -f "$SCRIPT_DIR/claude-proxy-amd64" "$SCRIPT_DIR/claude-proxy-arm64"
|
||||||
echo "Removed temporary binary files"
|
echo "Removed temporary binary files"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Claude sandbox image: Claude CLI + Node.js + Python + CCR
|
# Claude sandbox image: Claude CLI + Node.js + Python + claude-proxy
|
||||||
# Supports both amd64 and arm64 architectures
|
# Supports both amd64 and arm64 architectures
|
||||||
# Base: Ubuntu 24.04 LTS
|
# Base: Ubuntu 24.04 LTS
|
||||||
ARG REGISTRY=yaoapp
|
ARG REGISTRY=yaoapp
|
||||||
|
|
@ -25,6 +25,14 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
&& apt-get install -y nodejs \
|
&& apt-get install -y nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# GitHub CLI (gh) for repository operations
|
||||||
|
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||||
|
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y gh \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# npm global packages directory for sandbox user
|
# npm global packages directory for sandbox user
|
||||||
RUN mkdir -p /home/sandbox/.npm-global && \
|
RUN mkdir -p /home/sandbox/.npm-global && \
|
||||||
chown -R sandbox:sandbox /home/sandbox/.npm-global
|
chown -R sandbox:sandbox /home/sandbox/.npm-global
|
||||||
|
|
@ -39,54 +47,262 @@ ENV PATH="/home/sandbox/.npm-global/bin:${PATH}"
|
||||||
RUN npm install -g @anthropic-ai/claude-code || \
|
RUN npm install -g @anthropic-ai/claude-code || \
|
||||||
echo "Claude CLI installation skipped (may not be available yet)"
|
echo "Claude CLI installation skipped (may not be available yet)"
|
||||||
|
|
||||||
# Install Claude Code Router (CCR) for third-party LLM support
|
# Create Claude CLI configuration for auto-approve all operations
|
||||||
# Supports: DeepSeek, GLM, Volcengine, OpenRouter, etc.
|
# This is CRITICAL for non-interactive sandbox usage
|
||||||
RUN npm install -g @musistudio/claude-code-router
|
# The --dangerously-skip-permissions flag alone is not enough;
|
||||||
|
# we also need the settings.json to fully bypass permission prompts
|
||||||
|
RUN mkdir -p /home/sandbox/.claude && \
|
||||||
|
cat > /home/sandbox/.claude/settings.json << 'EOF'
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"defaultMode": "bypassPermissions",
|
||||||
|
"allow": ["*"],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
# Create CCR config directory
|
|
||||||
RUN mkdir -p /home/sandbox/.claude-code-router
|
|
||||||
|
|
||||||
# Create entrypoint script for CCR daemon mode
|
|
||||||
USER root
|
USER root
|
||||||
RUN cat > /usr/local/bin/ccr-run << 'SCRIPT'
|
|
||||||
|
# Install claude-proxy (architecture-specific binary)
|
||||||
|
ARG TARGETARCH
|
||||||
|
COPY claude-proxy-${TARGETARCH} /usr/local/bin/claude-proxy
|
||||||
|
RUN chmod +x /usr/local/bin/claude-proxy
|
||||||
|
|
||||||
|
# Create claude-proxy startup script
|
||||||
|
RUN cat > /usr/local/bin/start-proxy << 'SCRIPT'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# CCR wrapper: starts CCR daemon and runs ccr code
|
# Claude Proxy startup script
|
||||||
# Usage: ccr-run "prompt" or ccr-run -c /path/to/config.json "prompt"
|
# Usage: start-proxy [options]
|
||||||
|
# Options are passed directly to claude-proxy
|
||||||
|
|
||||||
CONFIG=""
|
LOG_DIR="${WORKSPACE:-/workspace}"
|
||||||
while [[ $# -gt 0 ]]; do
|
LOG_FILE="${LOG_DIR}/proxy.log"
|
||||||
case $1 in
|
|
||||||
-c|--config)
|
|
||||||
CONFIG="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Apply config if provided
|
# Ensure log directory exists
|
||||||
if [ -n "$CONFIG" ] && [ -f "$CONFIG" ]; then
|
mkdir -p "$LOG_DIR" 2>/dev/null || true
|
||||||
cp "$CONFIG" ~/.claude-code-router/config.json
|
|
||||||
|
# Default environment variables (can be overridden)
|
||||||
|
export CLAUDE_PROXY_PORT="${CLAUDE_PROXY_PORT:-3456}"
|
||||||
|
|
||||||
|
# Start proxy with logging
|
||||||
|
exec /usr/local/bin/claude-proxy -v -l "$LOG_FILE" "$@"
|
||||||
|
SCRIPT
|
||||||
|
RUN chmod +x /usr/local/bin/start-proxy
|
||||||
|
|
||||||
|
# Create claude-run wrapper for easy usage (manual mode)
|
||||||
|
RUN cat > /usr/local/bin/claude-run << 'SCRIPT'
|
||||||
|
#!/bin/bash
|
||||||
|
# Claude CLI wrapper with proxy auto-start
|
||||||
|
# Usage: claude-run [claude options] "prompt"
|
||||||
|
#
|
||||||
|
# Environment variables:
|
||||||
|
# CLAUDE_PROXY_BACKEND - Backend API URL (required)
|
||||||
|
# CLAUDE_PROXY_API_KEY - Backend API Key (required)
|
||||||
|
# CLAUDE_PROXY_MODEL - Backend model name (required)
|
||||||
|
# CLAUDE_PROXY_PORT - Proxy port (default: 3456)
|
||||||
|
# WORKSPACE - Working directory (default: /workspace)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Check required environment variables
|
||||||
|
if [ -z "$CLAUDE_PROXY_BACKEND" ]; then
|
||||||
|
echo "Error: CLAUDE_PROXY_BACKEND is not set"
|
||||||
|
echo "Example: export CLAUDE_PROXY_BACKEND=https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start CCR daemon (nohup because ccr start -d doesn't work in containers)
|
if [ -z "$CLAUDE_PROXY_API_KEY" ]; then
|
||||||
nohup ccr start >/dev/null 2>&1 &
|
echo "Error: CLAUDE_PROXY_API_KEY is not set"
|
||||||
sleep 2
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Run ccr code
|
if [ -z "$CLAUDE_PROXY_MODEL" ]; then
|
||||||
exec ccr code -p "$*"
|
echo "Error: CLAUDE_PROXY_MODEL is not set"
|
||||||
|
echo "Example: export CLAUDE_PROXY_MODEL=glm-4-7-251222"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PORT="${CLAUDE_PROXY_PORT:-3456}"
|
||||||
|
WORKSPACE="${WORKSPACE:-/workspace}"
|
||||||
|
LOG_FILE="${WORKSPACE}/proxy.log"
|
||||||
|
|
||||||
|
# Check if proxy is already running
|
||||||
|
if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
|
||||||
|
echo "Proxy already running on port $PORT"
|
||||||
|
else
|
||||||
|
echo "Starting claude-proxy..."
|
||||||
|
mkdir -p "$WORKSPACE" 2>/dev/null || true
|
||||||
|
nohup /usr/local/bin/claude-proxy -v -l "$LOG_FILE" > /dev/null 2>&1 &
|
||||||
|
|
||||||
|
# Wait for proxy to start
|
||||||
|
for i in {1..10}; do
|
||||||
|
if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
|
||||||
|
echo "Proxy started successfully"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
|
||||||
|
echo "Error: Failed to start proxy"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set Claude CLI environment
|
||||||
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:${PORT}"
|
||||||
|
export ANTHROPIC_API_KEY="dummy"
|
||||||
|
|
||||||
|
# Change to workspace directory
|
||||||
|
cd "$WORKSPACE"
|
||||||
|
|
||||||
|
# Run Claude CLI with all arguments
|
||||||
|
exec claude "$@"
|
||||||
SCRIPT
|
SCRIPT
|
||||||
RUN chmod +x /usr/local/bin/ccr-run
|
RUN chmod +x /usr/local/bin/claude-run
|
||||||
|
|
||||||
|
# Create start-claude-proxy script for programmatic use (called by Yao)
|
||||||
|
# Config is read from /tmp/.yao/proxy.json (NOT /workspace/ for security - api_key/secrets hidden from user)
|
||||||
|
RUN cat > /usr/local/bin/start-claude-proxy << 'SCRIPT'
|
||||||
|
#!/bin/bash
|
||||||
|
# Start claude-proxy from config file or environment variables
|
||||||
|
# Config file: /tmp/.yao/proxy.json (secure location, not visible to user file manager)
|
||||||
|
# Format: {"backend": "...", "api_key": "...", "model": "...", "options": {...}, "secrets": {...}}
|
||||||
|
|
||||||
|
CONFIG_FILE="/tmp/.yao/proxy.json"
|
||||||
|
LOG_FILE="${WORKSPACE:-/workspace}/proxy.log"
|
||||||
|
PORT="${CLAUDE_PROXY_PORT:-3456}"
|
||||||
|
|
||||||
|
# Try to read from config file first
|
||||||
|
if [ -f "$CONFIG_FILE" ]; then
|
||||||
|
BACKEND=$(jq -r '.backend // empty' "$CONFIG_FILE" 2>/dev/null)
|
||||||
|
API_KEY=$(jq -r '.api_key // empty' "$CONFIG_FILE" 2>/dev/null)
|
||||||
|
MODEL=$(jq -r '.model // empty' "$CONFIG_FILE" 2>/dev/null)
|
||||||
|
# Read extra options as JSON string (e.g., {"thinking":{"type":"enabled"}})
|
||||||
|
OPTIONS=$(jq -c '.options // empty' "$CONFIG_FILE" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -n "$BACKEND" ]; then
|
||||||
|
export CLAUDE_PROXY_BACKEND="$BACKEND"
|
||||||
|
fi
|
||||||
|
if [ -n "$API_KEY" ]; then
|
||||||
|
export CLAUDE_PROXY_API_KEY="$API_KEY"
|
||||||
|
fi
|
||||||
|
if [ -n "$MODEL" ]; then
|
||||||
|
export CLAUDE_PROXY_MODEL="$MODEL"
|
||||||
|
fi
|
||||||
|
# Only set options if it's a valid non-empty JSON object
|
||||||
|
if [ -n "$OPTIONS" ] && [ "$OPTIONS" != "null" ] && [ "$OPTIONS" != "" ]; then
|
||||||
|
export CLAUDE_PROXY_OPTIONS="$OPTIONS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Export secrets as environment variables for Claude CLI to use
|
||||||
|
# e.g., {"GITHUB_TOKEN": "ghp_xxx"} -> export GITHUB_TOKEN=ghp_xxx
|
||||||
|
SECRETS=$(jq -c '.secrets // empty' "$CONFIG_FILE" 2>/dev/null)
|
||||||
|
if [ -n "$SECRETS" ] && [ "$SECRETS" != "null" ] && [ "$SECRETS" != "" ] && [ "$SECRETS" != "{}" ]; then
|
||||||
|
# Parse each key-value pair and export
|
||||||
|
for key in $(echo "$SECRETS" | jq -r 'keys[]' 2>/dev/null); do
|
||||||
|
value=$(echo "$SECRETS" | jq -r --arg k "$key" '.[$k]' 2>/dev/null)
|
||||||
|
if [ -n "$value" ] && [ "$value" != "null" ]; then
|
||||||
|
export "$key"="$value"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if we have the required config
|
||||||
|
if [ -z "$CLAUDE_PROXY_BACKEND" ] || [ -z "$CLAUDE_PROXY_API_KEY" ] || [ -z "$CLAUDE_PROXY_MODEL" ]; then
|
||||||
|
echo "Error: Missing proxy configuration"
|
||||||
|
echo "Either set environment variables or create $CONFIG_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if already running
|
||||||
|
if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
|
||||||
|
echo "claude-proxy already running"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start proxy with environment variables explicitly passed
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
|
||||||
|
nohup env \
|
||||||
|
CLAUDE_PROXY_BACKEND="$CLAUDE_PROXY_BACKEND" \
|
||||||
|
CLAUDE_PROXY_API_KEY="$CLAUDE_PROXY_API_KEY" \
|
||||||
|
CLAUDE_PROXY_MODEL="$CLAUDE_PROXY_MODEL" \
|
||||||
|
CLAUDE_PROXY_OPTIONS="$CLAUDE_PROXY_OPTIONS" \
|
||||||
|
/usr/local/bin/claude-proxy -v -l "$LOG_FILE" > /dev/null 2>&1 &
|
||||||
|
|
||||||
|
# Wait for startup
|
||||||
|
for i in {1..20}; do
|
||||||
|
if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
|
||||||
|
echo "claude-proxy started on port $PORT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Error: claude-proxy failed to start"
|
||||||
|
exit 1
|
||||||
|
SCRIPT
|
||||||
|
RUN chmod +x /usr/local/bin/start-claude-proxy
|
||||||
|
|
||||||
|
# Create entrypoint script
|
||||||
|
# Note: claude-proxy is started on-demand by Yao (via start-claude-proxy)
|
||||||
|
# or manually by user (via claude-run)
|
||||||
|
RUN cat > /usr/local/bin/entrypoint.sh << 'SCRIPT'
|
||||||
|
#!/bin/bash
|
||||||
|
# Container entrypoint
|
||||||
|
# claude-proxy is NOT auto-started here - it's started by:
|
||||||
|
# 1. Yao's sandbox executor (writes config to .claude-proxy.json, calls start-claude-proxy)
|
||||||
|
# 2. Manual usage via claude-run command
|
||||||
|
# 3. Direct invocation of start-claude-proxy
|
||||||
|
|
||||||
|
WORKSPACE="${WORKSPACE:-/workspace}"
|
||||||
|
PORT="${CLAUDE_PROXY_PORT:-3456}"
|
||||||
|
ENV_FILE="/tmp/claude-proxy-env"
|
||||||
|
|
||||||
|
# If proxy env vars are set AND proxy is not running, start it
|
||||||
|
# This supports docker run -e CLAUDE_PROXY_BACKEND=... usage
|
||||||
|
if [ -n "$CLAUDE_PROXY_BACKEND" ] && [ -n "$CLAUDE_PROXY_API_KEY" ] && [ -n "$CLAUDE_PROXY_MODEL" ]; then
|
||||||
|
if ! curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
|
||||||
|
/usr/local/bin/start-claude-proxy
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write env vars to a file that can be sourced
|
||||||
|
if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
|
||||||
|
echo "export ANTHROPIC_BASE_URL=http://127.0.0.1:${PORT}" > "$ENV_FILE"
|
||||||
|
echo "export ANTHROPIC_API_KEY=dummy" >> "$ENV_FILE"
|
||||||
|
chmod 644 "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Execute the command passed to docker run
|
||||||
|
exec "$@"
|
||||||
|
SCRIPT
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
# Create a wrapper that sources the env file
|
||||||
|
RUN cat > /usr/local/bin/claude-env << 'SCRIPT'
|
||||||
|
#!/bin/bash
|
||||||
|
# Source claude-proxy environment if available
|
||||||
|
if [ -f /tmp/claude-proxy-env ]; then
|
||||||
|
source /tmp/claude-proxy-env
|
||||||
|
fi
|
||||||
|
exec "$@"
|
||||||
|
SCRIPT
|
||||||
|
RUN chmod +x /usr/local/bin/claude-env
|
||||||
|
|
||||||
|
# Add sourcing to global bashrc so docker exec gets the vars
|
||||||
|
RUN echo '[ -f /tmp/claude-proxy-env ] && source /tmp/claude-proxy-env' >> /etc/bash.bashrc
|
||||||
|
|
||||||
USER sandbox
|
USER sandbox
|
||||||
|
|
||||||
# Verify installations
|
# Verify installations
|
||||||
RUN node --version && npm --version && python3 --version && \
|
RUN node --version && npm --version && python3 --version && \
|
||||||
claude --version || true && \
|
claude --version || true && \
|
||||||
ccr --version || true
|
claude-proxy --help 2>&1 | head -1 || true
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
CMD ["sleep", "infinity"]
|
CMD ["sleep", "infinity"]
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ type demuxReadCloser struct {
|
||||||
closer io.Closer
|
closer io.Closer
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
err error
|
err error
|
||||||
|
closed bool
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// newDemuxReadCloser creates a new demuxed reader from Docker multiplexed stream
|
// newDemuxReadCloser creates a new demuxed reader from Docker multiplexed stream
|
||||||
|
|
@ -60,11 +62,20 @@ func newDemuxReadCloser(src io.Reader, closer io.Closer) *demuxReadCloser {
|
||||||
defer close(d.done)
|
defer close(d.done)
|
||||||
defer pw.Close()
|
defer pw.Close()
|
||||||
|
|
||||||
|
fmt.Printf("[DEBUG demux] Starting stdcopy.StdCopy\n")
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
// Use stdcopy to demux stdout and stderr
|
// Use stdcopy to demux stdout and stderr
|
||||||
// We only care about stdout here, stderr goes to a discard writer
|
// We only care about stdout here, stderr goes to a discard writer
|
||||||
_, err := stdcopy.StdCopy(pw, io.Discard, src)
|
n, err := stdcopy.StdCopy(pw, io.Discard, src)
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
|
||||||
|
fmt.Printf("[DEBUG demux] stdcopy.StdCopy returned: bytes=%d, err=%v, elapsed=%v\n", n, err, elapsed)
|
||||||
|
|
||||||
if err != nil && err != io.EOF {
|
if err != nil && err != io.EOF {
|
||||||
|
d.mu.Lock()
|
||||||
d.err = err
|
d.err = err
|
||||||
|
d.mu.Unlock()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -76,15 +87,39 @@ func (d *demuxReadCloser) Read(p []byte) (int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *demuxReadCloser) Close() error {
|
func (d *demuxReadCloser) Close() error {
|
||||||
// Close the source to stop the demux goroutine
|
d.mu.Lock()
|
||||||
|
if d.closed {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d.closed = true
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
// Close the pipe writer first to signal EOF to any readers
|
||||||
|
// This will cause pipeReader.Read() to return io.EOF
|
||||||
|
d.pipeWriter.CloseWithError(io.EOF)
|
||||||
|
|
||||||
|
// Close the source connection to interrupt stdcopy.StdCopy
|
||||||
if d.closer != nil {
|
if d.closer != nil {
|
||||||
d.closer.Close()
|
d.closer.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the pipe reader to unblock any pending reads
|
// Close the pipe reader to unblock any pending reads
|
||||||
d.pipeReader.Close()
|
d.pipeReader.Close()
|
||||||
// Wait for demux goroutine to finish
|
|
||||||
<-d.done
|
// Wait for demux goroutine to finish with a timeout
|
||||||
return d.err
|
// Don't block forever if stdcopy.StdCopy is stuck
|
||||||
|
select {
|
||||||
|
case <-d.done:
|
||||||
|
// Normal completion
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
fmt.Printf("[DEBUG demux] Timeout waiting for demux goroutine to finish\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
d.mu.Lock()
|
||||||
|
err := d.err
|
||||||
|
d.mu.Unlock()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager manages sandbox containers
|
// Manager manages sandbox containers
|
||||||
|
|
@ -562,6 +597,39 @@ func (m *Manager) Remove(ctx context.Context, name string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KillProcess kills a process inside the container by name pattern
|
||||||
|
// This is used to forcefully stop long-running processes like Claude CLI
|
||||||
|
func (m *Manager) KillProcess(ctx context.Context, name string, processPattern string) error {
|
||||||
|
c, ok := m.containers.Load(name)
|
||||||
|
if !ok {
|
||||||
|
return ErrContainerNotFound
|
||||||
|
}
|
||||||
|
cont := c.(*Container)
|
||||||
|
|
||||||
|
// Use pkill to kill processes matching the pattern
|
||||||
|
// -f matches against the full command line
|
||||||
|
// Use SIGKILL (-9) to ensure the process is killed immediately
|
||||||
|
cmd := []string{"pkill", "-9", "-f", processPattern}
|
||||||
|
|
||||||
|
execConfig := container.ExecOptions{
|
||||||
|
Cmd: cmd,
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
execResp, err := m.dockerClient.ContainerExecCreate(ctx, cont.ID, execConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create exec for kill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the exec
|
||||||
|
if err := m.dockerClient.ContainerExecStart(ctx, execResp.ID, container.ExecStartOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("failed to start exec for kill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// List returns all containers for a user
|
// List returns all containers for a user
|
||||||
func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error) {
|
func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error) {
|
||||||
var result []*Container
|
var result []*Container
|
||||||
|
|
|
||||||
214
sandbox/proxy/README.md
Normal file
214
sandbox/proxy/README.md
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
# Claude API Proxy
|
||||||
|
|
||||||
|
A lightweight API proxy that allows Claude CLI to use any OpenAI-compatible backend API (Volcengine, DeepSeek, GLM, etc.).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Zero dependencies**: Uses only Go standard library
|
||||||
|
- **Lightweight**: Single executable binary
|
||||||
|
- **True streaming**: Direct SSE forwarding, no buffering
|
||||||
|
- **Full tool calling support**: Both streaming and non-streaming
|
||||||
|
- **Image content support**: Base64 and URL formats
|
||||||
|
- **Multi-architecture**: Supports amd64 and arm64
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude CLI (Anthropic Messages API)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
claude-proxy (localhost:3456)
|
||||||
|
│
|
||||||
|
│ Convert: Anthropic → OpenAI
|
||||||
|
▼
|
||||||
|
OpenAI-compatible Backend (Volcengine/DeepSeek/GLM...)
|
||||||
|
│
|
||||||
|
│ Convert: OpenAI → Anthropic
|
||||||
|
▼
|
||||||
|
Claude CLI (Real-time streaming output)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command Line Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude-proxy [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-p, --port <port> Listen port (default: 3456)
|
||||||
|
-b, --backend <url> Backend API URL (required)
|
||||||
|
-m, --model <model> Backend model name (required)
|
||||||
|
-k, --api-key <key> Backend API key (required)
|
||||||
|
-l, --log <path> Log file path
|
||||||
|
-t, --timeout <seconds> Request timeout (default: 300)
|
||||||
|
-v, --verbose Verbose logging
|
||||||
|
-h, --help Show help
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
CLAUDE_PROXY_PORT Listen port
|
||||||
|
CLAUDE_PROXY_BACKEND Backend API URL
|
||||||
|
CLAUDE_PROXY_MODEL Backend model name
|
||||||
|
CLAUDE_PROXY_API_KEY Backend API key
|
||||||
|
CLAUDE_PROXY_TIMEOUT Timeout in seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Method 1: Command Line Arguments
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using Volcengine GLM-4
|
||||||
|
claude-proxy -b https://ark.cn-beijing.volces.com/api/v3/chat/completions \
|
||||||
|
-m glm-4-7-251222 \
|
||||||
|
-k your-api-key \
|
||||||
|
-v
|
||||||
|
|
||||||
|
# Using DeepSeek
|
||||||
|
claude-proxy -b https://api.deepseek.com/chat/completions \
|
||||||
|
-m deepseek-chat \
|
||||||
|
-k your-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CLAUDE_PROXY_BACKEND="https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||||
|
export CLAUDE_PROXY_API_KEY="your-api-key"
|
||||||
|
export CLAUDE_PROXY_MODEL="glm-4-7-251222"
|
||||||
|
claude-proxy -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 3: Config File (Inside Container)
|
||||||
|
|
||||||
|
Create config file at `/workspace/.claude-proxy.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"backend": "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||||||
|
"api_key": "your-api-key",
|
||||||
|
"model": "glm-4-7-251222"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
start-claude-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Container Usage
|
||||||
|
|
||||||
|
### Docker Run (via Environment Variables)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-e CLAUDE_PROXY_BACKEND="https://ark.cn-beijing.volces.com/api/v3/chat/completions" \
|
||||||
|
-e CLAUDE_PROXY_API_KEY="your-api-key" \
|
||||||
|
-e CLAUDE_PROXY_MODEL="your-model-name" \
|
||||||
|
yaoapp/sandbox-claude:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The container will automatically start claude-proxy on startup.
|
||||||
|
|
||||||
|
### Using claude-run Wrapper
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enter the container
|
||||||
|
docker exec -it <container> bash
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
export CLAUDE_PROXY_BACKEND="https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||||
|
export CLAUDE_PROXY_API_KEY="your-api-key"
|
||||||
|
export CLAUDE_PROXY_MODEL="your-model-name"
|
||||||
|
|
||||||
|
# Use claude-run wrapper (auto-starts proxy)
|
||||||
|
claude-run --dangerously-skip-permissions "Build me a website"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Claude CLI Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ensure proxy is running
|
||||||
|
curl http://127.0.0.1:3456/health
|
||||||
|
|
||||||
|
# Set Claude CLI environment
|
||||||
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:3456
|
||||||
|
export ANTHROPIC_API_KEY=dummy
|
||||||
|
|
||||||
|
# Use Claude CLI
|
||||||
|
claude -p --dangerously-skip-permissions --permission-mode bypassPermissions "Build me a website"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Claude CLI Common Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic usage (max permissions, no questions)
|
||||||
|
claude -p --dangerously-skip-permissions --permission-mode bypassPermissions "your task"
|
||||||
|
|
||||||
|
# Streaming JSON output
|
||||||
|
claude -p --dangerously-skip-permissions --output-format stream-json --verbose "your task"
|
||||||
|
|
||||||
|
# Interactive mode (real-time streaming output)
|
||||||
|
claude --dangerously-skip-permissions "your task"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option Reference
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
| ------------------------------------- | ----------------------------------------- |
|
||||||
|
| `-p, --print` | Print mode, exit after output |
|
||||||
|
| `--dangerously-skip-permissions` | Skip all permission checks |
|
||||||
|
| `--permission-mode bypassPermissions` | Bypass permission mode |
|
||||||
|
| `--output-format stream-json` | Output JSON stream |
|
||||||
|
| `--verbose` | Verbose output (required for stream-json) |
|
||||||
|
|
||||||
|
## Viewing Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View proxy logs inside container
|
||||||
|
tail -f /workspace/proxy.log
|
||||||
|
|
||||||
|
# Check health status
|
||||||
|
curl http://127.0.0.1:3456/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Backends
|
||||||
|
|
||||||
|
| Backend | API URL |
|
||||||
|
| ---------------------------- | ----------------------------------------------------------- |
|
||||||
|
| Volcengine GLM | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` |
|
||||||
|
| Volcengine DeepSeek | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` |
|
||||||
|
| DeepSeek Official | `https://api.deepseek.com/chat/completions` |
|
||||||
|
| OpenAI | `https://api.openai.com/v1/chat/completions` |
|
||||||
|
| Other OpenAI-compatible APIs | Custom URL |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### POST /v1/messages
|
||||||
|
|
||||||
|
Main endpoint, accepts Anthropic Messages API format requests.
|
||||||
|
|
||||||
|
### GET /health
|
||||||
|
|
||||||
|
Health check endpoint, returns `{"status": "ok"}`.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local build
|
||||||
|
go build -o claude-proxy ./cmd/claude-proxy/
|
||||||
|
|
||||||
|
# Cross-compile
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o claude-proxy-amd64 ./cmd/claude-proxy/
|
||||||
|
GOOS=linux GOARCH=arm64 go build -o claude-proxy-arm64 ./cmd/claude-proxy/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Yao Integration
|
||||||
|
|
||||||
|
Yao's sandbox executor automatically:
|
||||||
|
|
||||||
|
1. Writes connector config to `/workspace/.claude-proxy.json` when creating container
|
||||||
|
2. Calls `start-claude-proxy` to start the proxy
|
||||||
|
3. Sets `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` environment variables
|
||||||
|
4. Executes Claude CLI commands
|
||||||
|
|
||||||
|
No manual configuration required.
|
||||||
7
sandbox/proxy/cmd/claude-proxy/main.go
Normal file
7
sandbox/proxy/cmd/claude-proxy/main.go
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/yaoapp/yao/sandbox/proxy"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
proxy.Main()
|
||||||
|
}
|
||||||
461
sandbox/proxy/convert.go
Normal file
461
sandbox/proxy/convert.go
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// convertRequest converts an Anthropic request to OpenAI format
|
||||||
|
func (s *Server) convertRequest(req *AnthropicRequest) *OpenAIRequest {
|
||||||
|
// Get max_tokens from options if specified, otherwise use request value
|
||||||
|
maxTokens := req.MaxTokens
|
||||||
|
if s.config.Options != nil {
|
||||||
|
if mt, ok := s.config.Options["max_tokens"]; ok {
|
||||||
|
switch v := mt.(type) {
|
||||||
|
case float64:
|
||||||
|
maxTokens = int(v)
|
||||||
|
case int:
|
||||||
|
maxTokens = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get temperature from options if specified
|
||||||
|
temperature := req.Temperature
|
||||||
|
if s.config.Options != nil {
|
||||||
|
if temp, ok := s.config.Options["temperature"]; ok {
|
||||||
|
if v, ok := temp.(float64); ok {
|
||||||
|
temperature = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiReq := &OpenAIRequest{
|
||||||
|
Model: s.config.Model,
|
||||||
|
MaxTokens: maxTokens,
|
||||||
|
Stream: req.Stream,
|
||||||
|
Temperature: temperature,
|
||||||
|
TopP: req.TopP,
|
||||||
|
Stop: req.StopSequences,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass through extra options (e.g., thinking, reasoning_effort, etc.)
|
||||||
|
// These are backend-specific parameters that will be merged into the request
|
||||||
|
if s.config.Options != nil {
|
||||||
|
openaiReq.ExtraOptions = make(map[string]interface{})
|
||||||
|
for k, v := range s.config.Options {
|
||||||
|
// Skip standard fields that are already handled
|
||||||
|
switch k {
|
||||||
|
case "max_tokens", "temperature", "model", "key", "proxy":
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
openaiReq.ExtraOptions[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert messages
|
||||||
|
openaiReq.Messages = s.convertMessages(req.Messages, req.System)
|
||||||
|
|
||||||
|
// Convert tools
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
openaiReq.Tools = s.convertTools(req.Tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert tool choice
|
||||||
|
if req.ToolChoice != nil {
|
||||||
|
openaiReq.ToolChoice = s.convertToolChoice(req.ToolChoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
return openaiReq
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertMessages converts Anthropic messages to OpenAI format
|
||||||
|
func (s *Server) convertMessages(msgs []AnthropicMsg, system interface{}) []OpenAIMsg {
|
||||||
|
var result []OpenAIMsg
|
||||||
|
|
||||||
|
// Handle system message
|
||||||
|
if system != nil {
|
||||||
|
systemText := extractSystemText(system)
|
||||||
|
if systemText != "" {
|
||||||
|
result = append(result, OpenAIMsg{
|
||||||
|
Role: "system",
|
||||||
|
Content: systemText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert each message
|
||||||
|
for _, msg := range msgs {
|
||||||
|
converted := s.convertMessage(msg)
|
||||||
|
result = append(result, converted...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertMessage converts a single Anthropic message to OpenAI format
|
||||||
|
func (s *Server) convertMessage(msg AnthropicMsg) []OpenAIMsg {
|
||||||
|
var result []OpenAIMsg
|
||||||
|
|
||||||
|
// Handle content
|
||||||
|
switch content := msg.Content.(type) {
|
||||||
|
case string:
|
||||||
|
result = append(result, OpenAIMsg{
|
||||||
|
Role: mapRole(msg.Role),
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
|
||||||
|
case []interface{}:
|
||||||
|
// Check if this contains tool results
|
||||||
|
var toolResults []ContentBlock
|
||||||
|
var otherContent []interface{}
|
||||||
|
|
||||||
|
for _, item := range content {
|
||||||
|
block := parseContentBlock(item)
|
||||||
|
if block.Type == "tool_result" {
|
||||||
|
toolResults = append(toolResults, block)
|
||||||
|
} else {
|
||||||
|
otherContent = append(otherContent, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert tool results to separate tool messages
|
||||||
|
for _, tr := range toolResults {
|
||||||
|
toolMsg := OpenAIMsg{
|
||||||
|
Role: "tool",
|
||||||
|
ToolCallID: tr.ToolUseID,
|
||||||
|
Content: extractToolResultContent(tr.Content),
|
||||||
|
}
|
||||||
|
result = append(result, toolMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert other content
|
||||||
|
if len(otherContent) > 0 {
|
||||||
|
openaiContent := s.convertContentBlocks(otherContent)
|
||||||
|
if len(openaiContent) == 1 && openaiContent[0].Type == "text" {
|
||||||
|
result = append(result, OpenAIMsg{
|
||||||
|
Role: mapRole(msg.Role),
|
||||||
|
Content: openaiContent[0].Text,
|
||||||
|
})
|
||||||
|
} else if len(openaiContent) > 0 {
|
||||||
|
result = append(result, OpenAIMsg{
|
||||||
|
Role: mapRole(msg.Role),
|
||||||
|
Content: openaiContent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle assistant message with tool_use
|
||||||
|
if msg.Role == "assistant" {
|
||||||
|
toolCalls := extractToolUseBlocks(content)
|
||||||
|
if len(toolCalls) > 0 {
|
||||||
|
// Find or create assistant message
|
||||||
|
found := false
|
||||||
|
for i := range result {
|
||||||
|
if result[i].Role == "assistant" {
|
||||||
|
result[i].ToolCalls = toolCalls
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
result = append(result, OpenAIMsg{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "",
|
||||||
|
ToolCalls: toolCalls,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertContentBlocks converts Anthropic content blocks to OpenAI format
|
||||||
|
func (s *Server) convertContentBlocks(blocks []interface{}) []OpenAIContent {
|
||||||
|
var result []OpenAIContent
|
||||||
|
|
||||||
|
for _, item := range blocks {
|
||||||
|
block := parseContentBlock(item)
|
||||||
|
|
||||||
|
switch block.Type {
|
||||||
|
case "text":
|
||||||
|
result = append(result, OpenAIContent{
|
||||||
|
Type: "text",
|
||||||
|
Text: block.Text,
|
||||||
|
})
|
||||||
|
|
||||||
|
case "image":
|
||||||
|
if block.Source != nil {
|
||||||
|
imageURL := convertImageSource(block.Source)
|
||||||
|
result = append(result, OpenAIContent{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: imageURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tool_use", "tool_result":
|
||||||
|
// Handled separately
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertImageSource converts Anthropic image source to OpenAI image URL
|
||||||
|
func convertImageSource(source *ImageSource) *OpenAIImageURL {
|
||||||
|
if source == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch source.Type {
|
||||||
|
case "base64":
|
||||||
|
// Convert to data URI
|
||||||
|
mediaType := source.MediaType
|
||||||
|
if mediaType == "" {
|
||||||
|
mediaType = "image/jpeg"
|
||||||
|
}
|
||||||
|
return &OpenAIImageURL{
|
||||||
|
URL: fmt.Sprintf("data:%s;base64,%s", mediaType, source.Data),
|
||||||
|
}
|
||||||
|
case "url":
|
||||||
|
return &OpenAIImageURL{
|
||||||
|
URL: source.URL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertTools converts Anthropic tools to OpenAI format
|
||||||
|
func (s *Server) convertTools(tools []AnthropicTool) []OpenAITool {
|
||||||
|
var result []OpenAITool
|
||||||
|
|
||||||
|
for _, tool := range tools {
|
||||||
|
result = append(result, OpenAITool{
|
||||||
|
Type: "function",
|
||||||
|
Function: OpenAIFunction{
|
||||||
|
Name: tool.Name,
|
||||||
|
Description: tool.Description,
|
||||||
|
Parameters: tool.InputSchema,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertToolChoice converts Anthropic tool choice to OpenAI format
|
||||||
|
func (s *Server) convertToolChoice(choice *AnthropicToolChoice) interface{} {
|
||||||
|
if choice == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch choice.Type {
|
||||||
|
case "auto":
|
||||||
|
return "auto"
|
||||||
|
case "any":
|
||||||
|
return "required"
|
||||||
|
case "tool":
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]string{
|
||||||
|
"name": choice.Name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "none":
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertResponse converts an OpenAI response to Anthropic format
|
||||||
|
func (s *Server) convertResponse(resp *OpenAIResponse) *AnthropicResponse {
|
||||||
|
result := &AnthropicResponse{
|
||||||
|
ID: generateID("msg_"),
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Content: []ContentBlock{},
|
||||||
|
Model: s.config.Model,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Choices) > 0 {
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
|
||||||
|
// Convert content
|
||||||
|
if content, ok := choice.Message.Content.(string); ok && content != "" {
|
||||||
|
result.Content = append(result.Content, ContentBlock{
|
||||||
|
Type: "text",
|
||||||
|
Text: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert tool calls
|
||||||
|
for _, tc := range choice.Message.ToolCalls {
|
||||||
|
var input interface{}
|
||||||
|
json.Unmarshal([]byte(tc.Function.Arguments), &input)
|
||||||
|
|
||||||
|
result.Content = append(result.Content, ContentBlock{
|
||||||
|
Type: "tool_use",
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Input: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert stop reason
|
||||||
|
stopReason := mapFinishReason(choice.FinishReason)
|
||||||
|
result.StopReason = &stopReason
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert usage
|
||||||
|
if resp.Usage != nil {
|
||||||
|
result.Usage = &Usage{
|
||||||
|
InputTokens: resp.Usage.PromptTokens,
|
||||||
|
OutputTokens: resp.Usage.CompletionTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func extractSystemText(system interface{}) string {
|
||||||
|
switch s := system.(type) {
|
||||||
|
case string:
|
||||||
|
return s
|
||||||
|
case []interface{}:
|
||||||
|
var texts []string
|
||||||
|
for _, item := range s {
|
||||||
|
if block, ok := item.(map[string]interface{}); ok {
|
||||||
|
if text, ok := block["text"].(string); ok {
|
||||||
|
// Skip billing headers and other metadata
|
||||||
|
if strings.HasPrefix(text, "x-anthropic-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
texts = append(texts, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Concatenate all system texts with newlines
|
||||||
|
if len(texts) > 0 {
|
||||||
|
return strings.Join(texts, "\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseContentBlock(item interface{}) ContentBlock {
|
||||||
|
var block ContentBlock
|
||||||
|
|
||||||
|
switch v := item.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
if t, ok := v["type"].(string); ok {
|
||||||
|
block.Type = t
|
||||||
|
}
|
||||||
|
if text, ok := v["text"].(string); ok {
|
||||||
|
block.Text = text
|
||||||
|
}
|
||||||
|
if id, ok := v["id"].(string); ok {
|
||||||
|
block.ID = id
|
||||||
|
}
|
||||||
|
if name, ok := v["name"].(string); ok {
|
||||||
|
block.Name = name
|
||||||
|
}
|
||||||
|
if input, ok := v["input"]; ok {
|
||||||
|
block.Input = input
|
||||||
|
}
|
||||||
|
if toolUseID, ok := v["tool_use_id"].(string); ok {
|
||||||
|
block.ToolUseID = toolUseID
|
||||||
|
}
|
||||||
|
if content, ok := v["content"]; ok {
|
||||||
|
block.Content = content
|
||||||
|
}
|
||||||
|
if isError, ok := v["is_error"].(bool); ok {
|
||||||
|
block.IsError = isError
|
||||||
|
}
|
||||||
|
if source, ok := v["source"].(map[string]interface{}); ok {
|
||||||
|
block.Source = parseImageSource(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImageSource(source map[string]interface{}) *ImageSource {
|
||||||
|
if source == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ImageSource{}
|
||||||
|
if t, ok := source["type"].(string); ok {
|
||||||
|
result.Type = t
|
||||||
|
}
|
||||||
|
if mediaType, ok := source["media_type"].(string); ok {
|
||||||
|
result.MediaType = mediaType
|
||||||
|
}
|
||||||
|
if data, ok := source["data"].(string); ok {
|
||||||
|
result.Data = data
|
||||||
|
}
|
||||||
|
if url, ok := source["url"].(string); ok {
|
||||||
|
result.URL = url
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractToolUseBlocks(content []interface{}) []OpenAIToolCall {
|
||||||
|
var result []OpenAIToolCall
|
||||||
|
|
||||||
|
for _, item := range content {
|
||||||
|
block := parseContentBlock(item)
|
||||||
|
if block.Type == "tool_use" {
|
||||||
|
args, _ := json.Marshal(block.Input)
|
||||||
|
result = append(result, OpenAIToolCall{
|
||||||
|
ID: block.ID,
|
||||||
|
Type: "function",
|
||||||
|
Function: OpenAIFunctionCall{
|
||||||
|
Name: block.Name,
|
||||||
|
Arguments: string(args),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractToolResultContent(content interface{}) string {
|
||||||
|
switch c := content.(type) {
|
||||||
|
case string:
|
||||||
|
return c
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range c {
|
||||||
|
if block, ok := item.(map[string]interface{}); ok {
|
||||||
|
if block["type"] == "text" {
|
||||||
|
if text, ok := block["text"].(string); ok {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapRole(role string) string {
|
||||||
|
switch role {
|
||||||
|
case "user":
|
||||||
|
return "user"
|
||||||
|
case "assistant":
|
||||||
|
return "assistant"
|
||||||
|
default:
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
}
|
||||||
551
sandbox/proxy/main.go
Normal file
551
sandbox/proxy/main.go
Normal file
|
|
@ -0,0 +1,551 @@
|
||||||
|
// Package proxy provides a lightweight API proxy that translates
|
||||||
|
// Anthropic Messages API to OpenAI Chat Completions API.
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the proxy server configuration
|
||||||
|
type Config struct {
|
||||||
|
Port int
|
||||||
|
Backend string
|
||||||
|
Model string
|
||||||
|
APIKey string
|
||||||
|
Timeout int
|
||||||
|
Verbose bool
|
||||||
|
LogFile string
|
||||||
|
Options map[string]interface{} // Extra options to pass to backend (e.g., thinking, max_tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the API proxy server
|
||||||
|
type Server struct {
|
||||||
|
config *Config
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main is the entry point for the proxy server
|
||||||
|
func Main() {
|
||||||
|
config := parseFlags()
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
log.Fatalf("Configuration error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup log file if specified
|
||||||
|
if config.LogFile != "" {
|
||||||
|
f, err := os.OpenFile(config.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to open log file: %v", err)
|
||||||
|
}
|
||||||
|
// Write to both file and stdout
|
||||||
|
mw := io.MultiWriter(os.Stdout, f)
|
||||||
|
log.SetOutput(mw)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := NewServer(config)
|
||||||
|
addr := fmt.Sprintf(":%d", config.Port)
|
||||||
|
|
||||||
|
log.Printf("Claude API Proxy starting on %s", addr)
|
||||||
|
log.Printf("Backend: %s", config.Backend)
|
||||||
|
log.Printf("Model: %s", config.Model)
|
||||||
|
if len(config.Options) > 0 {
|
||||||
|
optBytes, _ := json.Marshal(config.Options)
|
||||||
|
log.Printf("Options: %s", string(optBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/v1/messages", server.handleMessages)
|
||||||
|
http.HandleFunc("/health", server.handleHealth)
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||||
|
log.Fatalf("Server failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlags() *Config {
|
||||||
|
config := &Config{}
|
||||||
|
|
||||||
|
flag.IntVar(&config.Port, "p", 0, "Listen port")
|
||||||
|
flag.IntVar(&config.Port, "port", 0, "Listen port")
|
||||||
|
flag.StringVar(&config.Backend, "b", "", "Backend API URL")
|
||||||
|
flag.StringVar(&config.Backend, "backend", "", "Backend API URL")
|
||||||
|
flag.StringVar(&config.Model, "m", "", "Backend model name")
|
||||||
|
flag.StringVar(&config.Model, "model", "", "Backend model name")
|
||||||
|
flag.StringVar(&config.APIKey, "k", "", "Backend API key")
|
||||||
|
flag.StringVar(&config.APIKey, "api-key", "", "Backend API key")
|
||||||
|
flag.IntVar(&config.Timeout, "t", 0, "Request timeout in seconds")
|
||||||
|
flag.IntVar(&config.Timeout, "timeout", 0, "Request timeout in seconds")
|
||||||
|
flag.BoolVar(&config.Verbose, "v", false, "Verbose logging")
|
||||||
|
flag.BoolVar(&config.Verbose, "verbose", false, "Verbose logging")
|
||||||
|
flag.StringVar(&config.LogFile, "l", "", "Log file path")
|
||||||
|
flag.StringVar(&config.LogFile, "log", "", "Log file path")
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Override with environment variables if flags not set
|
||||||
|
if config.Port == 0 {
|
||||||
|
if v := os.Getenv("CLAUDE_PROXY_PORT"); v != "" {
|
||||||
|
config.Port, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.Port == 0 {
|
||||||
|
config.Port = 3456
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Backend == "" {
|
||||||
|
config.Backend = os.Getenv("CLAUDE_PROXY_BACKEND")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Model == "" {
|
||||||
|
config.Model = os.Getenv("CLAUDE_PROXY_MODEL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.APIKey == "" {
|
||||||
|
config.APIKey = os.Getenv("CLAUDE_PROXY_API_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Timeout == 0 {
|
||||||
|
if v := os.Getenv("CLAUDE_PROXY_TIMEOUT"); v != "" {
|
||||||
|
config.Timeout, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.Timeout == 0 {
|
||||||
|
config.Timeout = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse extra options from environment variable (JSON format)
|
||||||
|
// Example: CLAUDE_PROXY_OPTIONS='{"thinking":{"type":"enabled"},"max_tokens":65536}'
|
||||||
|
if optionsStr := os.Getenv("CLAUDE_PROXY_OPTIONS"); optionsStr != "" {
|
||||||
|
var options map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(optionsStr), &options); err != nil {
|
||||||
|
log.Printf("Warning: failed to parse CLAUDE_PROXY_OPTIONS: %v", err)
|
||||||
|
} else {
|
||||||
|
config.Options = options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if the configuration is valid
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.Backend == "" {
|
||||||
|
return fmt.Errorf("backend URL is required (-b or CLAUDE_PROXY_BACKEND)")
|
||||||
|
}
|
||||||
|
if c.Model == "" {
|
||||||
|
return fmt.Errorf("model name is required (-m or CLAUDE_PROXY_MODEL)")
|
||||||
|
}
|
||||||
|
if c.APIKey == "" {
|
||||||
|
return fmt.Errorf("API key is required (-k or CLAUDE_PROXY_API_KEY)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new proxy server
|
||||||
|
func NewServer(config *Config) *Server {
|
||||||
|
return &Server{
|
||||||
|
config: config,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: time.Duration(config.Timeout) * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHealth handles health check requests
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessages handles the /v1/messages endpoint
|
||||||
|
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read request body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
s.errorResponse(w, http.StatusBadRequest, "invalid_request", "Failed to read request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
if s.config.Verbose {
|
||||||
|
log.Printf("Received request: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Anthropic request
|
||||||
|
var anthropicReq AnthropicRequest
|
||||||
|
if err := json.Unmarshal(body, &anthropicReq); err != nil {
|
||||||
|
s.errorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to OpenAI request
|
||||||
|
openaiReq := s.convertRequest(&anthropicReq)
|
||||||
|
|
||||||
|
// Forward to backend
|
||||||
|
if anthropicReq.Stream {
|
||||||
|
s.handleStreamingRequest(w, openaiReq)
|
||||||
|
} else {
|
||||||
|
s.handleNonStreamingRequest(w, openaiReq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNonStreamingRequest handles non-streaming requests
|
||||||
|
func (s *Server) handleNonStreamingRequest(w http.ResponseWriter, openaiReq *OpenAIRequest) {
|
||||||
|
openaiReq.Stream = false
|
||||||
|
|
||||||
|
resp, err := s.forwardRequest(openaiReq)
|
||||||
|
if err != nil {
|
||||||
|
s.errorResponse(w, http.StatusBadGateway, "backend_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
s.errorResponse(w, http.StatusBadGateway, "backend_error", "Failed to read backend response")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.config.Verbose {
|
||||||
|
log.Printf("Backend response: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
w.Write(body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse OpenAI response
|
||||||
|
var openaiResp OpenAIResponse
|
||||||
|
if err := json.Unmarshal(body, &openaiResp); err != nil {
|
||||||
|
s.errorResponse(w, http.StatusBadGateway, "backend_error", "Invalid backend response")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Anthropic response
|
||||||
|
anthropicResp := s.convertResponse(&openaiResp)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(anthropicResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStreamingRequest handles streaming requests with SSE
|
||||||
|
func (s *Server) handleStreamingRequest(w http.ResponseWriter, openaiReq *OpenAIRequest) {
|
||||||
|
openaiReq.Stream = true
|
||||||
|
openaiReq.StreamOptions = &StreamOptions{IncludeUsage: true}
|
||||||
|
|
||||||
|
resp, err := s.forwardRequest(openaiReq)
|
||||||
|
if err != nil {
|
||||||
|
s.errorResponse(w, http.StatusBadGateway, "backend_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
w.Write(body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set SSE headers
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
s.errorResponse(w, http.StatusInternalServerError, "server_error", "Streaming not supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message_start event
|
||||||
|
msgID := generateID("msg_")
|
||||||
|
startEvent := AnthropicStreamEvent{
|
||||||
|
Type: "message_start",
|
||||||
|
Message: &AnthropicResponse{
|
||||||
|
ID: msgID,
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Content: []ContentBlock{},
|
||||||
|
Model: s.config.Model,
|
||||||
|
StopReason: nil,
|
||||||
|
StopSequence: nil,
|
||||||
|
Usage: &Usage{InputTokens: 0, OutputTokens: 0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.writeSSE(w, flusher, startEvent)
|
||||||
|
|
||||||
|
// Process SSE stream from backend
|
||||||
|
s.processStream(w, flusher, resp.Body, msgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// processStream processes the SSE stream from the backend
|
||||||
|
func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body io.Reader, msgID string) {
|
||||||
|
scanner := bufio.NewScanner(body)
|
||||||
|
// Increase buffer size for large responses
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
|
||||||
|
var contentBlockStarted bool
|
||||||
|
var currentToolCall *ToolCallAccumulator
|
||||||
|
var toolCalls []*ToolCallAccumulator
|
||||||
|
var contentIndex int
|
||||||
|
var finishReason string
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
|
||||||
|
if !strings.HasPrefix(line, "data: ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data := strings.TrimPrefix(line, "data: ")
|
||||||
|
if data == "[DONE]" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunk OpenAIStreamChunk
|
||||||
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||||
|
if s.config.Verbose {
|
||||||
|
log.Printf("Failed to parse chunk: %s", data)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chunk.Choices) == 0 {
|
||||||
|
// Usage update at the end
|
||||||
|
if chunk.Usage != nil {
|
||||||
|
usageEvent := AnthropicStreamEvent{
|
||||||
|
Type: "message_delta",
|
||||||
|
Delta: &DeltaContent{
|
||||||
|
StopReason: &finishReason,
|
||||||
|
},
|
||||||
|
Usage: &Usage{
|
||||||
|
InputTokens: chunk.Usage.PromptTokens,
|
||||||
|
OutputTokens: chunk.Usage.CompletionTokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.writeSSE(w, flusher, usageEvent)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := chunk.Choices[0]
|
||||||
|
|
||||||
|
// Handle finish reason
|
||||||
|
if choice.FinishReason != "" {
|
||||||
|
finishReason = mapFinishReason(choice.FinishReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tool calls
|
||||||
|
if len(choice.Delta.ToolCalls) > 0 {
|
||||||
|
for _, tc := range choice.Delta.ToolCalls {
|
||||||
|
if tc.Index != nil {
|
||||||
|
idx := *tc.Index
|
||||||
|
// New tool call
|
||||||
|
if idx >= len(toolCalls) {
|
||||||
|
// Close previous content block if exists
|
||||||
|
if contentBlockStarted && currentToolCall == nil {
|
||||||
|
stopEvent := AnthropicStreamEvent{
|
||||||
|
Type: "content_block_stop",
|
||||||
|
Index: contentIndex - 1,
|
||||||
|
}
|
||||||
|
s.writeSSE(w, flusher, stopEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentToolCall = &ToolCallAccumulator{
|
||||||
|
Index: idx,
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Args: "",
|
||||||
|
}
|
||||||
|
toolCalls = append(toolCalls, currentToolCall)
|
||||||
|
|
||||||
|
// Send content_block_start for tool_use
|
||||||
|
startEvent := AnthropicStreamEvent{
|
||||||
|
Type: "content_block_start",
|
||||||
|
Index: contentIndex,
|
||||||
|
ContentBlock: &ContentBlock{
|
||||||
|
Type: "tool_use",
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Input: map[string]interface{}{}, // Required empty object for streaming
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.writeSSE(w, flusher, startEvent)
|
||||||
|
contentIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate arguments
|
||||||
|
if tc.Function.Arguments != "" {
|
||||||
|
currentToolCall.Args += tc.Function.Arguments
|
||||||
|
deltaEvent := AnthropicStreamEvent{
|
||||||
|
Type: "content_block_delta",
|
||||||
|
Index: contentIndex - 1,
|
||||||
|
Delta: &DeltaContent{
|
||||||
|
Type: "input_json_delta",
|
||||||
|
PartialJSON: tc.Function.Arguments,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.writeSSE(w, flusher, deltaEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle text content
|
||||||
|
if choice.Delta.Content != "" {
|
||||||
|
if !contentBlockStarted {
|
||||||
|
// Send content_block_start
|
||||||
|
startEvent := AnthropicStreamEvent{
|
||||||
|
Type: "content_block_start",
|
||||||
|
Index: contentIndex,
|
||||||
|
ContentBlock: &ContentBlock{
|
||||||
|
Type: "text",
|
||||||
|
Text: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.writeSSE(w, flusher, startEvent)
|
||||||
|
contentBlockStarted = true
|
||||||
|
contentIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send content_block_delta
|
||||||
|
deltaEvent := AnthropicStreamEvent{
|
||||||
|
Type: "content_block_delta",
|
||||||
|
Index: contentIndex - 1,
|
||||||
|
Delta: &DeltaContent{
|
||||||
|
Type: "text_delta",
|
||||||
|
Text: choice.Delta.Content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.writeSSE(w, flusher, deltaEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close any open content blocks
|
||||||
|
if contentBlockStarted || len(toolCalls) > 0 {
|
||||||
|
stopEvent := AnthropicStreamEvent{
|
||||||
|
Type: "content_block_stop",
|
||||||
|
Index: contentIndex - 1,
|
||||||
|
}
|
||||||
|
s.writeSSE(w, flusher, stopEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message_delta with stop reason
|
||||||
|
if finishReason == "" {
|
||||||
|
finishReason = "end_turn"
|
||||||
|
}
|
||||||
|
deltaEvent := AnthropicStreamEvent{
|
||||||
|
Type: "message_delta",
|
||||||
|
Delta: &DeltaContent{
|
||||||
|
StopReason: &finishReason,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.writeSSE(w, flusher, deltaEvent)
|
||||||
|
|
||||||
|
// Send message_stop
|
||||||
|
stopEvent := AnthropicStreamEvent{
|
||||||
|
Type: "message_stop",
|
||||||
|
}
|
||||||
|
s.writeSSE(w, flusher, stopEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSSE writes an SSE event to the response
|
||||||
|
func (s *Server) writeSSE(w http.ResponseWriter, flusher http.Flusher, event interface{}) {
|
||||||
|
data, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eventType := ""
|
||||||
|
if e, ok := event.(AnthropicStreamEvent); ok {
|
||||||
|
eventType = e.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
if eventType != "" {
|
||||||
|
fmt.Fprintf(w, "event: %s\n", eventType)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
if s.config.Verbose {
|
||||||
|
log.Printf("SSE event: %s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardRequest forwards a request to the backend
|
||||||
|
func (s *Server) forwardRequest(openaiReq *OpenAIRequest) (*http.Response, error) {
|
||||||
|
body, err := json.Marshal(openaiReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.config.Verbose {
|
||||||
|
log.Printf("Forwarding to backend: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, s.config.Backend, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.config.APIKey)
|
||||||
|
|
||||||
|
return s.client.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorResponse sends an error response in Anthropic format
|
||||||
|
func (s *Server) errorResponse(w http.ResponseWriter, status int, errType, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"type": "error",
|
||||||
|
"error": map[string]string{
|
||||||
|
"type": errType,
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateID generates a unique ID with a prefix
|
||||||
|
func generateID(prefix string) string {
|
||||||
|
return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapFinishReason maps OpenAI finish reasons to Anthropic stop reasons
|
||||||
|
func mapFinishReason(reason string) string {
|
||||||
|
switch reason {
|
||||||
|
case "stop":
|
||||||
|
return "end_turn"
|
||||||
|
case "length":
|
||||||
|
return "max_tokens"
|
||||||
|
case "tool_calls", "function_call":
|
||||||
|
return "tool_use"
|
||||||
|
case "content_filter":
|
||||||
|
return "end_turn"
|
||||||
|
default:
|
||||||
|
return "end_turn"
|
||||||
|
}
|
||||||
|
}
|
||||||
293
sandbox/proxy/types.go
Normal file
293
sandbox/proxy/types.go
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Anthropic API Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// AnthropicRequest represents a request to the Anthropic Messages API
|
||||||
|
type AnthropicRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []AnthropicMsg `json:"messages"`
|
||||||
|
System interface{} `json:"system,omitempty"` // string or []SystemBlock
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
TopK *int `json:"top_k,omitempty"`
|
||||||
|
StopSequences []string `json:"stop_sequences,omitempty"`
|
||||||
|
Tools []AnthropicTool `json:"tools,omitempty"`
|
||||||
|
ToolChoice *AnthropicToolChoice `json:"tool_choice,omitempty"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicMsg represents a message in Anthropic format
|
||||||
|
type AnthropicMsg struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content interface{} `json:"content"` // string or []ContentBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentBlock represents a content block in Anthropic messages
|
||||||
|
type ContentBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
|
||||||
|
// For text blocks
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
|
||||||
|
// For image blocks
|
||||||
|
Source *ImageSource `json:"source,omitempty"`
|
||||||
|
|
||||||
|
// For tool_use blocks
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Input interface{} `json:"input,omitempty"`
|
||||||
|
|
||||||
|
// For tool_result blocks
|
||||||
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||||
|
Content interface{} `json:"content,omitempty"` // string or []ContentBlock
|
||||||
|
IsError bool `json:"is_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageSource represents an image source in Anthropic format
|
||||||
|
type ImageSource struct {
|
||||||
|
Type string `json:"type"` // "base64" or "url"
|
||||||
|
MediaType string `json:"media_type,omitempty"` // e.g., "image/jpeg"
|
||||||
|
Data string `json:"data,omitempty"` // base64 encoded data
|
||||||
|
URL string `json:"url,omitempty"` // URL for url type
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemBlock represents a system message block
|
||||||
|
type SystemBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicTool represents a tool definition in Anthropic format
|
||||||
|
type AnthropicTool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
InputSchema interface{} `json:"input_schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicToolChoice represents tool choice in Anthropic format
|
||||||
|
type AnthropicToolChoice struct {
|
||||||
|
Type string `json:"type"` // "auto", "any", "tool"
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicResponse represents a response from the Anthropic Messages API
|
||||||
|
type AnthropicResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content []ContentBlock `json:"content"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
StopReason *string `json:"stop_reason"`
|
||||||
|
StopSequence *string `json:"stop_sequence,omitempty"`
|
||||||
|
Usage *Usage `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage represents token usage statistics
|
||||||
|
type Usage struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicStreamEvent represents an SSE event in Anthropic format
|
||||||
|
type AnthropicStreamEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Index int `json:"index,omitempty"`
|
||||||
|
Message *AnthropicResponse `json:"message,omitempty"`
|
||||||
|
ContentBlock *ContentBlock `json:"content_block,omitempty"`
|
||||||
|
Delta *DeltaContent `json:"delta,omitempty"`
|
||||||
|
Usage *Usage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeltaContent represents delta content in streaming
|
||||||
|
type DeltaContent struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
PartialJSON string `json:"partial_json,omitempty"`
|
||||||
|
StopReason *string `json:"stop_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// OpenAI API Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// OpenAIRequest represents a request to OpenAI Chat Completions API
|
||||||
|
type OpenAIRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []OpenAIMsg `json:"messages"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
Stop []string `json:"stop,omitempty"`
|
||||||
|
Tools []OpenAITool `json:"tools,omitempty"`
|
||||||
|
ToolChoice interface{} `json:"tool_choice,omitempty"` // "auto", "none", "required", or object
|
||||||
|
|
||||||
|
// Extra options for backend-specific parameters (e.g., thinking for GLM-4)
|
||||||
|
// These are merged into the final JSON request
|
||||||
|
ExtraOptions map[string]interface{} `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON custom marshaler to merge ExtraOptions into the request
|
||||||
|
func (r OpenAIRequest) MarshalJSON() ([]byte, error) {
|
||||||
|
// Create a map with standard fields
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"model": r.Model,
|
||||||
|
"messages": r.Messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.MaxTokens > 0 {
|
||||||
|
m["max_tokens"] = r.MaxTokens
|
||||||
|
}
|
||||||
|
if r.Stream {
|
||||||
|
m["stream"] = r.Stream
|
||||||
|
}
|
||||||
|
if r.StreamOptions != nil {
|
||||||
|
m["stream_options"] = r.StreamOptions
|
||||||
|
}
|
||||||
|
if r.Temperature != nil {
|
||||||
|
m["temperature"] = *r.Temperature
|
||||||
|
}
|
||||||
|
if r.TopP != nil {
|
||||||
|
m["top_p"] = *r.TopP
|
||||||
|
}
|
||||||
|
if len(r.Stop) > 0 {
|
||||||
|
m["stop"] = r.Stop
|
||||||
|
}
|
||||||
|
if len(r.Tools) > 0 {
|
||||||
|
m["tools"] = r.Tools
|
||||||
|
}
|
||||||
|
if r.ToolChoice != nil {
|
||||||
|
m["tool_choice"] = r.ToolChoice
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge extra options (backend-specific parameters like thinking, etc.)
|
||||||
|
for k, v := range r.ExtraOptions {
|
||||||
|
// Don't override standard fields
|
||||||
|
if _, exists := m[k]; !exists {
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamOptions represents stream options in OpenAI format
|
||||||
|
type StreamOptions struct {
|
||||||
|
IncludeUsage bool `json:"include_usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIMsg represents a message in OpenAI format
|
||||||
|
type OpenAIMsg struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content interface{} `json:"content,omitempty"` // string or []OpenAIContent
|
||||||
|
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIContent represents content in OpenAI messages (for multimodal)
|
||||||
|
type OpenAIContent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *OpenAIImageURL `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIImageURL represents an image URL in OpenAI format
|
||||||
|
type OpenAIImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Detail string `json:"detail,omitempty"` // "auto", "low", "high"
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAITool represents a tool definition in OpenAI format
|
||||||
|
type OpenAITool struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function OpenAIFunction `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIFunction represents a function in OpenAI tool
|
||||||
|
type OpenAIFunction struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Parameters interface{} `json:"parameters"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIToolCall represents a tool call in OpenAI format
|
||||||
|
type OpenAIToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function OpenAIFunctionCall `json:"function"`
|
||||||
|
Index *int `json:"index,omitempty"` // For streaming
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIFunctionCall represents a function call in OpenAI format
|
||||||
|
type OpenAIFunctionCall struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIResponse represents a response from OpenAI Chat Completions API
|
||||||
|
type OpenAIResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []OpenAIChoice `json:"choices"`
|
||||||
|
Usage *OpenAIUsage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIChoice represents a choice in OpenAI response
|
||||||
|
type OpenAIChoice struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Message OpenAIMsg `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIUsage represents usage statistics in OpenAI format
|
||||||
|
type OpenAIUsage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIStreamChunk represents a streaming chunk from OpenAI
|
||||||
|
type OpenAIStreamChunk struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []OpenAIStreamChoice `json:"choices"`
|
||||||
|
Usage *OpenAIUsage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIStreamChoice represents a choice in OpenAI streaming response
|
||||||
|
type OpenAIStreamChoice struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Delta OpenAIStreamDelta `json:"delta"`
|
||||||
|
FinishReason string `json:"finish_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIStreamDelta represents delta content in OpenAI streaming
|
||||||
|
type OpenAIStreamDelta struct {
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Internal Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// ToolCallAccumulator accumulates tool call data during streaming
|
||||||
|
type ToolCallAccumulator struct {
|
||||||
|
Index int
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Args string
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue