diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go deleted file mode 100644 index a91795715..000000000 --- a/pkg/providers/claude_cli_provider.go +++ /dev/null @@ -1,275 +0,0 @@ -package providers - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os/exec" - "strings" -) - -// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. -type ClaudeCliProvider struct { - command string - workspace string -} - -// NewClaudeCliProvider creates a new Claude CLI provider. -func NewClaudeCliProvider(workspace string) *ClaudeCliProvider { - return &ClaudeCliProvider{ - command: "claude", - workspace: workspace, - } -} - -// Chat implements LLMProvider.Chat by executing the claude CLI. -func (p *ClaudeCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - systemPrompt := p.buildSystemPrompt(messages, tools) - prompt := p.messagesToPrompt(messages) - - args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"} - if systemPrompt != "" { - args = append(args, "--system-prompt", systemPrompt) - } - if model != "" && model != "claude-code" { - args = append(args, "--model", model) - } - args = append(args, "-") // read from stdin - - cmd := exec.CommandContext(ctx, p.command, args...) - if p.workspace != "" { - cmd.Dir = p.workspace - } - cmd.Stdin = bytes.NewReader([]byte(prompt)) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - if stderrStr := stderr.String(); stderrStr != "" { - return nil, fmt.Errorf("claude cli error: %s", stderrStr) - } - return nil, fmt.Errorf("claude cli error: %w", err) - } - - return p.parseClaudeCliResponse(stdout.String()) -} - -// GetDefaultModel returns the default model identifier. -func (p *ClaudeCliProvider) GetDefaultModel() string { - return "claude-code" -} - -// messagesToPrompt converts messages to a CLI-compatible prompt string. -func (p *ClaudeCliProvider) messagesToPrompt(messages []Message) string { - var parts []string - - for _, msg := range messages { - switch msg.Role { - case "system": - // handled via --system-prompt flag - case "user": - parts = append(parts, "User: "+msg.Content) - case "assistant": - parts = append(parts, "Assistant: "+msg.Content) - case "tool": - parts = append(parts, fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content)) - } - } - - // Simplify single user message - if len(parts) == 1 && strings.HasPrefix(parts[0], "User: ") { - return strings.TrimPrefix(parts[0], "User: ") - } - - return strings.Join(parts, "\n") -} - -// buildSystemPrompt combines system messages and tool definitions. -func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDefinition) string { - var parts []string - - for _, msg := range messages { - if msg.Role == "system" { - parts = append(parts, msg.Content) - } - } - - if len(tools) > 0 { - parts = append(parts, p.buildToolsPrompt(tools)) - } - - return strings.Join(parts, "\n\n") -} - -// buildToolsPrompt creates the tool definitions section for the system prompt. -func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string { - var sb strings.Builder - - sb.WriteString("## Available Tools\n\n") - sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") - sb.WriteString("```json\n") - sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`) - sb.WriteString("\n```\n\n") - sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") - sb.WriteString("### Tool Definitions:\n\n") - - for _, tool := range tools { - if tool.Type != "function" { - continue - } - sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) - if tool.Function.Description != "" { - sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) - } - if len(tool.Function.Parameters) > 0 { - paramsJSON, _ := json.Marshal(tool.Function.Parameters) - sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON))) - } - sb.WriteString("\n") - } - - return sb.String() -} - -// parseClaudeCliResponse parses the JSON output from the claude CLI. -func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) { - var resp claudeCliJSONResponse - if err := json.Unmarshal([]byte(output), &resp); err != nil { - return nil, fmt.Errorf("failed to parse claude cli response: %w", err) - } - - if resp.IsError { - return nil, fmt.Errorf("claude cli returned error: %s", resp.Result) - } - - toolCalls := p.extractToolCalls(resp.Result) - - finishReason := "stop" - content := resp.Result - if len(toolCalls) > 0 { - finishReason = "tool_calls" - content = p.stripToolCallsJSON(resp.Result) - } - - var usage *UsageInfo - if resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 { - usage = &UsageInfo{ - PromptTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens, - CompletionTokens: resp.Usage.OutputTokens, - TotalTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.OutputTokens, - } - } - - return &LLMResponse{ - Content: strings.TrimSpace(content), - ToolCalls: toolCalls, - FinishReason: finishReason, - Usage: usage, - }, nil -} - -// extractToolCalls parses tool call JSON from the response text. -func (p *ClaudeCliProvider) extractToolCalls(text string) []ToolCall { - start := strings.Index(text, `{"tool_calls"`) - if start == -1 { - return nil - } - - end := findMatchingBrace(text, start) - if end == start { - return nil - } - - jsonStr := text[start:end] - - var wrapper struct { - ToolCalls []struct { - ID string `json:"id"` - Type string `json:"type"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` - } `json:"tool_calls"` - } - - if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil { - return nil - } - - var result []ToolCall - for _, tc := range wrapper.ToolCalls { - var args map[string]interface{} - json.Unmarshal([]byte(tc.Function.Arguments), &args) - - result = append(result, ToolCall{ - ID: tc.ID, - Type: tc.Type, - Name: tc.Function.Name, - Arguments: args, - Function: &FunctionCall{ - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - }, - }) - } - - return result -} - -// stripToolCallsJSON removes tool call JSON from response text. -func (p *ClaudeCliProvider) stripToolCallsJSON(text string) string { - start := strings.Index(text, `{"tool_calls"`) - if start == -1 { - return text - } - - end := findMatchingBrace(text, start) - if end == start { - return text - } - - return strings.TrimSpace(text[:start] + text[end:]) -} - -// findMatchingBrace finds the index after the closing brace matching the opening brace at pos. -func findMatchingBrace(text string, pos int) int { - depth := 0 - for i := pos; i < len(text); i++ { - if text[i] == '{' { - depth++ - } else if text[i] == '}' { - depth-- - if depth == 0 { - return i + 1 - } - } - } - return pos -} - -// claudeCliJSONResponse represents the JSON output from the claude CLI. -// Matches the real claude CLI v2.x output format. -type claudeCliJSONResponse struct { - Type string `json:"type"` - Subtype string `json:"subtype"` - IsError bool `json:"is_error"` - Result string `json:"result"` - SessionID string `json:"session_id"` - TotalCostUSD float64 `json:"total_cost_usd"` - DurationMS int `json:"duration_ms"` - DurationAPI int `json:"duration_api_ms"` - NumTurns int `json:"num_turns"` - Usage claudeCliUsageInfo `json:"usage"` -} - -// claudeCliUsageInfo represents token usage from the claude CLI response. -type claudeCliUsageInfo struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - CacheCreationInputTokens int `json:"cache_creation_input_tokens"` - CacheReadInputTokens int `json:"cache_read_input_tokens"` -} diff --git a/pkg/providers/claude_cli_provider_integration_test.go b/pkg/providers/claude_cli_provider_integration_test.go deleted file mode 100644 index 9d1131ac4..000000000 --- a/pkg/providers/claude_cli_provider_integration_test.go +++ /dev/null @@ -1,126 +0,0 @@ -//go:build integration - -package providers - -import ( - "context" - exec "os/exec" - "strings" - "testing" - "time" -) - -// TestIntegration_RealClaudeCLI tests the ClaudeCliProvider with a real claude CLI. -// Run with: go test -tags=integration ./pkg/providers/... -func TestIntegration_RealClaudeCLI(t *testing.T) { - // Check if claude CLI is available - path, err := exec.LookPath("claude") - if err != nil { - t.Skip("claude CLI not found in PATH, skipping integration test") - } - t.Logf("Using claude CLI at: %s", path) - - p := NewClaudeCliProvider(t.TempDir()) - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - resp, err := p.Chat(ctx, []Message{ - {Role: "user", Content: "Respond with only the word 'pong'. Nothing else."}, - }, nil, "", nil) - - if err != nil { - t.Fatalf("Chat() with real CLI error = %v", err) - } - - // Verify response structure - if resp.Content == "" { - t.Error("Content is empty") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - if resp.Usage == nil { - t.Error("Usage should not be nil from real CLI") - } else { - if resp.Usage.PromptTokens == 0 { - t.Error("PromptTokens should be > 0") - } - if resp.Usage.CompletionTokens == 0 { - t.Error("CompletionTokens should be > 0") - } - t.Logf("Usage: prompt=%d, completion=%d, total=%d", - resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens) - } - - t.Logf("Response content: %q", resp.Content) - - // Loose check - should contain "pong" somewhere (model might capitalize or add punctuation) - if !strings.Contains(strings.ToLower(resp.Content), "pong") { - t.Errorf("Content = %q, expected to contain 'pong'", resp.Content) - } -} - -func TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) { - if _, err := exec.LookPath("claude"); err != nil { - t.Skip("claude CLI not found in PATH") - } - - p := NewClaudeCliProvider(t.TempDir()) - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - resp, err := p.Chat(ctx, []Message{ - {Role: "system", Content: "You are a calculator. Only respond with numbers. No text."}, - {Role: "user", Content: "What is 2+2?"}, - }, nil, "", nil) - - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - - t.Logf("Response: %q", resp.Content) - - if !strings.Contains(resp.Content, "4") { - t.Errorf("Content = %q, expected to contain '4'", resp.Content) - } -} - -func TestIntegration_RealClaudeCLI_ParsesRealJSON(t *testing.T) { - if _, err := exec.LookPath("claude"); err != nil { - t.Skip("claude CLI not found in PATH") - } - - // Run claude directly and verify our parser handles real output - cmd := exec.Command("claude", "-p", "--output-format", "json", - "--dangerously-skip-permissions", "--no-chrome", "--no-session-persistence", "-") - cmd.Stdin = strings.NewReader("Say hi") - cmd.Dir = t.TempDir() - - output, err := cmd.Output() - if err != nil { - t.Fatalf("claude CLI failed: %v", err) - } - - t.Logf("Raw CLI output: %s", string(output)) - - // Verify our parser can handle real output - p := NewClaudeCliProvider("") - resp, err := p.parseClaudeCliResponse(string(output)) - if err != nil { - t.Fatalf("parseClaudeCliResponse() failed on real CLI output: %v", err) - } - - if resp.Content == "" { - t.Error("parsed Content is empty") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want stop", resp.FinishReason) - } - if resp.Usage == nil { - t.Error("Usage should not be nil") - } - - t.Logf("Parsed: content=%q, finish=%s, usage=%+v", resp.Content, resp.FinishReason, resp.Usage) -} diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go deleted file mode 100644 index 063530deb..000000000 --- a/pkg/providers/claude_cli_provider_test.go +++ /dev/null @@ -1,981 +0,0 @@ -package providers - -import ( - "context" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/sipeed/picoclaw/pkg/config" -) - -// --- Compile-time interface check --- - -var _ LLMProvider = (*ClaudeCliProvider)(nil) - -// --- Helper: create mock CLI scripts --- - -// createMockCLI creates a temporary script that simulates the claude CLI. -// Uses files for stdout/stderr to avoid shell quoting issues with JSON. -func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string { - t.Helper() - if runtime.GOOS == "windows" { - t.Skip("mock CLI scripts not supported on Windows") - } - - dir := t.TempDir() - - if stdout != "" { - if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0644); err != nil { - t.Fatal(err) - } - } - if stderr != "" { - if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0644); err != nil { - t.Fatal(err) - } - } - - var sb strings.Builder - sb.WriteString("#!/bin/sh\n") - if stderr != "" { - sb.WriteString(fmt.Sprintf("cat '%s/stderr.txt' >&2\n", dir)) - } - if stdout != "" { - sb.WriteString(fmt.Sprintf("cat '%s/stdout.txt'\n", dir)) - } - sb.WriteString(fmt.Sprintf("exit %d\n", exitCode)) - - script := filepath.Join(dir, "claude") - if err := os.WriteFile(script, []byte(sb.String()), 0755); err != nil { - t.Fatal(err) - } - return script -} - -// createSlowMockCLI creates a script that sleeps before responding (for context cancellation tests). -func createSlowMockCLI(t *testing.T, sleepSeconds int) string { - t.Helper() - if runtime.GOOS == "windows" { - t.Skip("mock CLI scripts not supported on Windows") - } - - dir := t.TempDir() - script := filepath.Join(dir, "claude") - content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds) - if err := os.WriteFile(script, []byte(content), 0755); err != nil { - t.Fatal(err) - } - return script -} - -// createArgCaptureCLI creates a script that captures CLI args to a file, then outputs JSON. -func createArgCaptureCLI(t *testing.T, argsFile string) string { - t.Helper() - if runtime.GOOS == "windows" { - t.Skip("mock CLI scripts not supported on Windows") - } - - dir := t.TempDir() - script := filepath.Join(dir, "claude") - content := fmt.Sprintf(`#!/bin/sh -echo "$@" > '%s' -cat <<'EOFMOCK' -{"type":"result","result":"ok","session_id":"test"} -EOFMOCK -`, argsFile) - if err := os.WriteFile(script, []byte(content), 0755); err != nil { - t.Fatal(err) - } - return script -} - -// --- Constructor tests --- - -func TestNewClaudeCliProvider(t *testing.T) { - p := NewClaudeCliProvider("/test/workspace") - if p == nil { - t.Fatal("NewClaudeCliProvider returned nil") - } - if p.workspace != "/test/workspace" { - t.Errorf("workspace = %q, want %q", p.workspace, "/test/workspace") - } - if p.command != "claude" { - t.Errorf("command = %q, want %q", p.command, "claude") - } -} - -func TestNewClaudeCliProvider_EmptyWorkspace(t *testing.T) { - p := NewClaudeCliProvider("") - if p.workspace != "" { - t.Errorf("workspace = %q, want empty", p.workspace) - } -} - -// --- GetDefaultModel tests --- - -func TestClaudeCliProvider_GetDefaultModel(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - if got := p.GetDefaultModel(); got != "claude-code" { - t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-code") - } -} - -// --- Chat() tests --- - -func TestChat_Success(t *testing.T) { - mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Hello from mock!","session_id":"sess_123","total_cost_usd":0.005,"duration_ms":200,"duration_api_ms":150,"num_turns":1,"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":100,"cache_read_input_tokens":0}}` - script := createMockCLI(t, mockJSON, "", 0) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - resp, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - if resp.Content != "Hello from mock!" { - t.Errorf("Content = %q, want %q", resp.Content, "Hello from mock!") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - if len(resp.ToolCalls) != 0 { - t.Errorf("ToolCalls len = %d, want 0", len(resp.ToolCalls)) - } - if resp.Usage == nil { - t.Fatal("Usage should not be nil") - } - if resp.Usage.PromptTokens != 110 { // 10 + 100 + 0 - t.Errorf("PromptTokens = %d, want 110", resp.Usage.PromptTokens) - } - if resp.Usage.CompletionTokens != 5 { - t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens) - } - if resp.Usage.TotalTokens != 115 { // 110 + 5 - t.Errorf("TotalTokens = %d, want 115", resp.Usage.TotalTokens) - } -} - -func TestChat_IsErrorResponse(t *testing.T) { - mockJSON := `{"type":"result","subtype":"error","is_error":true,"result":"Rate limit exceeded","session_id":"s1","total_cost_usd":0}` - script := createMockCLI(t, mockJSON, "", 0) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err == nil { - t.Fatal("Chat() expected error when is_error=true") - } - if !strings.Contains(err.Error(), "Rate limit exceeded") { - t.Errorf("error = %q, want to contain 'Rate limit exceeded'", err.Error()) - } -} - -func TestChat_WithToolCallsInResponse(t *testing.T) { - mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Checking weather.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"NYC\\\"}\"}}]}","session_id":"s1","total_cost_usd":0.01,"usage":{"input_tokens":5,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}` - script := createMockCLI(t, mockJSON, "", 0) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - resp, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "What's the weather?"}, - }, nil, "", nil) - - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - if resp.FinishReason != "tool_calls" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") - } - if len(resp.ToolCalls) != 1 { - t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls)) - } - if resp.ToolCalls[0].Name != "get_weather" { - t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "get_weather") - } - if resp.ToolCalls[0].Arguments["location"] != "NYC" { - t.Errorf("ToolCalls[0].Arguments[location] = %v, want NYC", resp.ToolCalls[0].Arguments["location"]) - } -} - -func TestChat_StderrError(t *testing.T) { - script := createMockCLI(t, "", "Error: rate limited", 1) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err == nil { - t.Fatal("Chat() expected error") - } - if !strings.Contains(err.Error(), "rate limited") { - t.Errorf("error = %q, want to contain 'rate limited'", err.Error()) - } -} - -func TestChat_NonZeroExitNoStderr(t *testing.T) { - script := createMockCLI(t, "", "", 1) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err == nil { - t.Fatal("Chat() expected error for non-zero exit") - } - if !strings.Contains(err.Error(), "claude cli error") { - t.Errorf("error = %q, want to contain 'claude cli error'", err.Error()) - } -} - -func TestChat_CommandNotFound(t *testing.T) { - p := NewClaudeCliProvider(t.TempDir()) - p.command = "/nonexistent/claude-binary-that-does-not-exist" - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err == nil { - t.Fatal("Chat() expected error for missing command") - } -} - -func TestChat_InvalidResponseJSON(t *testing.T) { - script := createMockCLI(t, "not valid json at all", "", 0) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err == nil { - t.Fatal("Chat() expected error for invalid JSON") - } - if !strings.Contains(err.Error(), "failed to parse claude cli response") { - t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error()) - } -} - -func TestChat_ContextCancellation(t *testing.T) { - script := createSlowMockCLI(t, 2) // sleep 2s - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - start := time.Now() - _, err := p.Chat(ctx, []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - elapsed := time.Since(start) - - if err == nil { - t.Fatal("Chat() expected error on context cancellation") - } - // Should fail well before the full 2s sleep completes - if elapsed > 3*time.Second { - t.Errorf("Chat() took %v, expected to fail faster via context cancellation", elapsed) - } -} - -func TestChat_PassesSystemPromptFlag(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args.txt") - script := createArgCaptureCLI(t, argsFile) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "system", Content: "Be helpful."}, - {Role: "user", Content: "Hi"}, - }, nil, "", nil) - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - - argsBytes, err := os.ReadFile(argsFile) - if err != nil { - t.Fatalf("failed to read args file: %v", err) - } - args := string(argsBytes) - if !strings.Contains(args, "--system-prompt") { - t.Errorf("CLI args missing --system-prompt, got: %s", args) - } -} - -func TestChat_PassesModelFlag(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args.txt") - script := createArgCaptureCLI(t, argsFile) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hi"}, - }, nil, "claude-sonnet-4-5-20250929", nil) - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - - argsBytes, _ := os.ReadFile(argsFile) - args := string(argsBytes) - if !strings.Contains(args, "--model") { - t.Errorf("CLI args missing --model, got: %s", args) - } - if !strings.Contains(args, "claude-sonnet-4-5-20250929") { - t.Errorf("CLI args missing model name, got: %s", args) - } -} - -func TestChat_SkipsModelFlagForClaudeCode(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args.txt") - script := createArgCaptureCLI(t, argsFile) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hi"}, - }, nil, "claude-code", nil) - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - - argsBytes, _ := os.ReadFile(argsFile) - args := string(argsBytes) - if strings.Contains(args, "--model") { - t.Errorf("CLI args should NOT contain --model for claude-code, got: %s", args) - } -} - -func TestChat_SkipsModelFlagForEmptyModel(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args.txt") - script := createArgCaptureCLI(t, argsFile) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hi"}, - }, nil, "", nil) - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - - argsBytes, _ := os.ReadFile(argsFile) - args := string(argsBytes) - if strings.Contains(args, "--model") { - t.Errorf("CLI args should NOT contain --model for empty model, got: %s", args) - } -} - -func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) { - mockJSON := `{"type":"result","result":"ok","session_id":"s"}` - script := createMockCLI(t, mockJSON, "", 0) - - p := NewClaudeCliProvider("") - p.command = script - - resp, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err != nil { - t.Fatalf("Chat() with empty workspace error = %v", err) - } - if resp.Content != "ok" { - t.Errorf("Content = %q, want %q", resp.Content, "ok") - } -} - -// --- CreateProvider factory tests --- - -func TestCreateProvider_ClaudeCli(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Provider = "claude-cli" - cfg.Agents.Defaults.Workspace = "/test/ws" - - provider, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claude-cli) error = %v", err) - } - - cliProvider, ok := provider.(*ClaudeCliProvider) - if !ok { - t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider) - } - if cliProvider.workspace != "/test/ws" { - t.Errorf("workspace = %q, want %q", cliProvider.workspace, "/test/ws") - } -} - -func TestCreateProvider_ClaudeCode(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Provider = "claude-code" - - provider, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claude-code) error = %v", err) - } - if _, ok := provider.(*ClaudeCliProvider); !ok { - t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider) - } -} - -func TestCreateProvider_ClaudeCodec(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Provider = "claudecode" - - provider, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claudecode) error = %v", err) - } - if _, ok := provider.(*ClaudeCliProvider); !ok { - t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider) - } -} - -func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Provider = "claude-cli" - cfg.Agents.Defaults.Workspace = "" - - provider, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider error = %v", err) - } - - cliProvider, ok := provider.(*ClaudeCliProvider) - if !ok { - t.Fatalf("returned %T, want *ClaudeCliProvider", provider) - } - if cliProvider.workspace != "." { - t.Errorf("workspace = %q, want %q (default)", cliProvider.workspace, ".") - } -} - -// --- messagesToPrompt tests --- - -func TestMessagesToPrompt_SingleUser(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "user", Content: "Hello"}, - } - got := p.messagesToPrompt(messages) - want := "Hello" - if got != want { - t.Errorf("messagesToPrompt() = %q, want %q", got, want) - } -} - -func TestMessagesToPrompt_Conversation(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "user", Content: "Hi"}, - {Role: "assistant", Content: "Hello!"}, - {Role: "user", Content: "How are you?"}, - } - got := p.messagesToPrompt(messages) - want := "User: Hi\nAssistant: Hello!\nUser: How are you?" - if got != want { - t.Errorf("messagesToPrompt() = %q, want %q", got, want) - } -} - -func TestMessagesToPrompt_WithSystemMessage(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "system", Content: "You are helpful."}, - {Role: "user", Content: "Hello"}, - } - got := p.messagesToPrompt(messages) - want := "Hello" - if got != want { - t.Errorf("messagesToPrompt() = %q, want %q", got, want) - } -} - -func TestMessagesToPrompt_WithToolResults(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "user", Content: "What's the weather?"}, - {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_123"}, - } - got := p.messagesToPrompt(messages) - if !strings.Contains(got, "[Tool Result for call_123]") { - t.Errorf("messagesToPrompt() missing tool result marker, got %q", got) - } - if !strings.Contains(got, `{"temp": 72}`) { - t.Errorf("messagesToPrompt() missing tool result content, got %q", got) - } -} - -func TestMessagesToPrompt_EmptyMessages(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - got := p.messagesToPrompt(nil) - if got != "" { - t.Errorf("messagesToPrompt(nil) = %q, want empty", got) - } -} - -func TestMessagesToPrompt_OnlySystemMessages(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "system", Content: "System 1"}, - {Role: "system", Content: "System 2"}, - } - got := p.messagesToPrompt(messages) - if got != "" { - t.Errorf("messagesToPrompt() with only system msgs = %q, want empty", got) - } -} - -// --- buildSystemPrompt tests --- - -func TestBuildSystemPrompt_NoSystemNoTools(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "user", Content: "Hi"}, - } - got := p.buildSystemPrompt(messages, nil) - if got != "" { - t.Errorf("buildSystemPrompt() = %q, want empty", got) - } -} - -func TestBuildSystemPrompt_SystemOnly(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "system", Content: "You are helpful."}, - {Role: "user", Content: "Hi"}, - } - got := p.buildSystemPrompt(messages, nil) - if got != "You are helpful." { - t.Errorf("buildSystemPrompt() = %q, want %q", got, "You are helpful.") - } -} - -func TestBuildSystemPrompt_MultipleSystemMessages(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "system", Content: "You are helpful."}, - {Role: "system", Content: "Be concise."}, - {Role: "user", Content: "Hi"}, - } - got := p.buildSystemPrompt(messages, nil) - if !strings.Contains(got, "You are helpful.") { - t.Error("missing first system message") - } - if !strings.Contains(got, "Be concise.") { - t.Error("missing second system message") - } - // Should be joined with double newline - want := "You are helpful.\n\nBe concise." - if got != want { - t.Errorf("buildSystemPrompt() = %q, want %q", got, want) - } -} - -func TestBuildSystemPrompt_WithTools(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "system", Content: "You are helpful."}, - } - tools := []ToolDefinition{ - { - Type: "function", - Function: ToolFunctionDefinition{ - Name: "get_weather", - Description: "Get weather for a location", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "location": map[string]interface{}{"type": "string"}, - }, - }, - }, - }, - } - got := p.buildSystemPrompt(messages, tools) - if !strings.Contains(got, "You are helpful.") { - t.Error("buildSystemPrompt() missing system message") - } - if !strings.Contains(got, "get_weather") { - t.Error("buildSystemPrompt() missing tool definition") - } - if !strings.Contains(got, "Available Tools") { - t.Error("buildSystemPrompt() missing tools header") - } -} - -func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - tools := []ToolDefinition{ - { - Type: "function", - Function: ToolFunctionDefinition{ - Name: "test_tool", - Description: "A test tool", - }, - }, - } - got := p.buildSystemPrompt(nil, tools) - if !strings.Contains(got, "test_tool") { - t.Error("should include tool definitions even without system messages") - } -} - -// --- buildToolsPrompt tests --- - -func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - tools := []ToolDefinition{ - {Type: "other", Function: ToolFunctionDefinition{Name: "skip_me"}}, - {Type: "function", Function: ToolFunctionDefinition{Name: "include_me", Description: "Included"}}, - } - got := p.buildToolsPrompt(tools) - if strings.Contains(got, "skip_me") { - t.Error("buildToolsPrompt() should skip non-function tools") - } - if !strings.Contains(got, "include_me") { - t.Error("buildToolsPrompt() should include function tools") - } -} - -func TestBuildToolsPrompt_NoDescription(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - tools := []ToolDefinition{ - {Type: "function", Function: ToolFunctionDefinition{Name: "bare_tool"}}, - } - got := p.buildToolsPrompt(tools) - if !strings.Contains(got, "bare_tool") { - t.Error("should include tool name") - } - if strings.Contains(got, "Description:") { - t.Error("should not include Description: line when empty") - } -} - -func TestBuildToolsPrompt_NoParameters(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - tools := []ToolDefinition{ - {Type: "function", Function: ToolFunctionDefinition{ - Name: "no_params_tool", - Description: "A tool with no parameters", - }}, - } - got := p.buildToolsPrompt(tools) - if strings.Contains(got, "Parameters:") { - t.Error("should not include Parameters: section when nil") - } -} - -// --- parseClaudeCliResponse tests --- - -func TestParseClaudeCliResponse_TextOnly(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - output := `{"type":"result","subtype":"success","is_error":false,"result":"Hello, world!","session_id":"abc123","total_cost_usd":0.01,"duration_ms":500,"usage":{"input_tokens":10,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}` - - resp, err := p.parseClaudeCliResponse(output) - if err != nil { - t.Fatalf("parseClaudeCliResponse() error = %v", err) - } - if resp.Content != "Hello, world!" { - t.Errorf("Content = %q, want %q", resp.Content, "Hello, world!") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - if len(resp.ToolCalls) != 0 { - t.Errorf("ToolCalls = %d, want 0", len(resp.ToolCalls)) - } - if resp.Usage == nil { - t.Fatal("Usage should not be nil") - } - if resp.Usage.PromptTokens != 10 { - t.Errorf("PromptTokens = %d, want 10", resp.Usage.PromptTokens) - } - if resp.Usage.CompletionTokens != 20 { - t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens) - } -} - -func TestParseClaudeCliResponse_EmptyResult(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - output := `{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"abc"}` - - resp, err := p.parseClaudeCliResponse(output) - if err != nil { - t.Fatalf("error = %v", err) - } - if resp.Content != "" { - t.Errorf("Content = %q, want empty", resp.Content) - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } -} - -func TestParseClaudeCliResponse_IsError(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - output := `{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong","session_id":"abc"}` - - _, err := p.parseClaudeCliResponse(output) - if err == nil { - t.Fatal("expected error when is_error=true") - } - if !strings.Contains(err.Error(), "Something went wrong") { - t.Errorf("error = %q, want to contain 'Something went wrong'", err.Error()) - } -} - -func TestParseClaudeCliResponse_NoUsage(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - output := `{"type":"result","subtype":"success","is_error":false,"result":"hi","session_id":"s"}` - - resp, err := p.parseClaudeCliResponse(output) - if err != nil { - t.Fatalf("error = %v", err) - } - if resp.Usage != nil { - t.Errorf("Usage should be nil when no tokens, got %+v", resp.Usage) - } -} - -func TestParseClaudeCliResponse_InvalidJSON(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - _, err := p.parseClaudeCliResponse("not json") - if err == nil { - t.Fatal("expected error for invalid JSON") - } - if !strings.Contains(err.Error(), "failed to parse claude cli response") { - t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error()) - } -} - -func TestParseClaudeCliResponse_WithToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - output := `{"type":"result","subtype":"success","is_error":false,"result":"Let me check.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"Tokyo\\\"}\"}}]}","session_id":"abc123","total_cost_usd":0.01}` - - resp, err := p.parseClaudeCliResponse(output) - if err != nil { - t.Fatalf("error = %v", err) - } - if resp.FinishReason != "tool_calls" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") - } - if len(resp.ToolCalls) != 1 { - t.Fatalf("ToolCalls = %d, want 1", len(resp.ToolCalls)) - } - tc := resp.ToolCalls[0] - if tc.Name != "get_weather" { - t.Errorf("Name = %q, want %q", tc.Name, "get_weather") - } - if tc.Function == nil { - t.Fatal("Function is nil") - } - if tc.Function.Name != "get_weather" { - t.Errorf("Function.Name = %q, want %q", tc.Function.Name, "get_weather") - } - if tc.Arguments["location"] != "Tokyo" { - t.Errorf("Arguments[location] = %v, want Tokyo", tc.Arguments["location"]) - } - if strings.Contains(resp.Content, "tool_calls") { - t.Errorf("Content should not contain tool_calls JSON, got %q", resp.Content) - } - if resp.Content != "Let me check." { - t.Errorf("Content = %q, want %q", resp.Content, "Let me check.") - } -} - -func TestParseClaudeCliResponse_WhitespaceResult(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - output := `{"type":"result","subtype":"success","is_error":false,"result":" hello \n ","session_id":"s"}` - - resp, err := p.parseClaudeCliResponse(output) - if err != nil { - t.Fatalf("error = %v", err) - } - if resp.Content != "hello" { - t.Errorf("Content = %q, want %q (should be trimmed)", resp.Content, "hello") - } -} - -// --- extractToolCalls tests --- - -func TestExtractToolCalls_NoToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - got := p.extractToolCalls("Just a regular response.") - if len(got) != 0 { - t.Errorf("extractToolCalls() = %d, want 0", len(got)) - } -} - -func TestExtractToolCalls_WithToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `Here's the result: -{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}` - - got := p.extractToolCalls(text) - if len(got) != 1 { - t.Fatalf("extractToolCalls() = %d, want 1", len(got)) - } - if got[0].ID != "call_1" { - t.Errorf("ID = %q, want %q", got[0].ID, "call_1") - } - if got[0].Name != "test" { - t.Errorf("Name = %q, want %q", got[0].Name, "test") - } - if got[0].Type != "function" { - t.Errorf("Type = %q, want %q", got[0].Type, "function") - } -} - -func TestExtractToolCalls_InvalidJSON(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - got := p.extractToolCalls(`{"tool_calls":invalid}`) - if len(got) != 0 { - t.Errorf("extractToolCalls() with invalid JSON = %d, want 0", len(got)) - } -} - -func TestExtractToolCalls_MultipleToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"/tmp/out\",\"content\":\"hello\"}"}}]}` - - got := p.extractToolCalls(text) - if len(got) != 2 { - t.Fatalf("extractToolCalls() = %d, want 2", len(got)) - } - if got[0].Name != "read_file" { - t.Errorf("[0].Name = %q, want %q", got[0].Name, "read_file") - } - if got[1].Name != "write_file" { - t.Errorf("[1].Name = %q, want %q", got[1].Name, "write_file") - } - // Verify arguments were parsed - if got[0].Arguments["path"] != "/tmp/test" { - t.Errorf("[0].Arguments[path] = %v, want /tmp/test", got[0].Arguments["path"]) - } - if got[1].Arguments["content"] != "hello" { - t.Errorf("[1].Arguments[content] = %v, want hello", got[1].Arguments["content"]) - } -} - -func TestExtractToolCalls_UnmatchedBrace(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - got := p.extractToolCalls(`{"tool_calls":[{"id":"call_1"`) - if len(got) != 0 { - t.Errorf("extractToolCalls() with unmatched brace = %d, want 0", len(got)) - } -} - -func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{\"num\":42,\"flag\":true,\"name\":\"test\"}"}}]}` - - got := p.extractToolCalls(text) - if len(got) != 1 { - t.Fatalf("len = %d, want 1", len(got)) - } - // Verify different argument types - if got[0].Arguments["num"] != float64(42) { - t.Errorf("Arguments[num] = %v (%T), want 42", got[0].Arguments["num"], got[0].Arguments["num"]) - } - if got[0].Arguments["flag"] != true { - t.Errorf("Arguments[flag] = %v, want true", got[0].Arguments["flag"]) - } - if got[0].Arguments["name"] != "test" { - t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"]) - } - // Verify raw arguments string is preserved in FunctionCall - if got[0].Function.Arguments == "" { - t.Error("Function.Arguments should contain raw JSON string") - } -} - -// --- stripToolCallsJSON tests --- - -func TestStripToolCallsJSON(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `Let me check the weather. -{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]} -Done.` - - got := p.stripToolCallsJSON(text) - if strings.Contains(got, "tool_calls") { - t.Errorf("should remove tool_calls JSON, got %q", got) - } - if !strings.Contains(got, "Let me check the weather.") { - t.Errorf("should keep text before, got %q", got) - } - if !strings.Contains(got, "Done.") { - t.Errorf("should keep text after, got %q", got) - } -} - -func TestStripToolCallsJSON_NoToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := "Just regular text." - got := p.stripToolCallsJSON(text) - if got != text { - t.Errorf("stripToolCallsJSON() = %q, want %q", got, text) - } -} - -func TestStripToolCallsJSON_OnlyToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}` - got := p.stripToolCallsJSON(text) - if got != "" { - t.Errorf("stripToolCallsJSON() = %q, want empty", got) - } -} - -// --- findMatchingBrace tests --- - -func TestFindMatchingBrace(t *testing.T) { - tests := []struct { - text string - pos int - want int - }{ - {`{"a":1}`, 0, 7}, - {`{"a":{"b":2}}`, 0, 13}, - {`text {"a":1} more`, 5, 12}, - {`{unclosed`, 0, 0}, // no match returns pos - {`{}`, 0, 2}, // empty object - {`{{{}}}`, 0, 6}, // deeply nested - {`{"a":"b{c}d"}`, 0, 13}, // braces in strings (simplified matcher) - } - for _, tt := range tests { - got := findMatchingBrace(tt.text, tt.pos) - if got != tt.want { - t.Errorf("findMatchingBrace(%q, %d) = %d, want %d", tt.text, tt.pos, got, tt.want) - } - } -} diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go deleted file mode 100644 index ae6aca96d..000000000 --- a/pkg/providers/claude_provider.go +++ /dev/null @@ -1,207 +0,0 @@ -package providers - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/anthropics/anthropic-sdk-go" - "github.com/anthropics/anthropic-sdk-go/option" - "github.com/sipeed/picoclaw/pkg/auth" -) - -type ClaudeProvider struct { - client *anthropic.Client - tokenSource func() (string, error) -} - -func NewClaudeProvider(token string) *ClaudeProvider { - client := anthropic.NewClient( - option.WithAuthToken(token), - option.WithBaseURL("https://api.anthropic.com"), - ) - return &ClaudeProvider{client: &client} -} - -func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider { - p := NewClaudeProvider(token) - p.tokenSource = tokenSource - return p -} - -func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - var opts []option.RequestOption - if p.tokenSource != nil { - tok, err := p.tokenSource() - if err != nil { - return nil, fmt.Errorf("refreshing token: %w", err) - } - opts = append(opts, option.WithAuthToken(tok)) - } - - params, err := buildClaudeParams(messages, tools, model, options) - if err != nil { - return nil, err - } - - resp, err := p.client.Messages.New(ctx, params, opts...) - if err != nil { - return nil, fmt.Errorf("claude API call: %w", err) - } - - return parseClaudeResponse(resp), nil -} - -func (p *ClaudeProvider) GetDefaultModel() string { - return "claude-sonnet-4-5-20250929" -} - -func buildClaudeParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) { - var system []anthropic.TextBlockParam - var anthropicMessages []anthropic.MessageParam - - for _, msg := range messages { - switch msg.Role { - case "system": - system = append(system, anthropic.TextBlockParam{Text: msg.Content}) - case "user": - if msg.ToolCallID != "" { - anthropicMessages = append(anthropicMessages, - anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), - ) - } else { - anthropicMessages = append(anthropicMessages, - anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)), - ) - } - case "assistant": - if len(msg.ToolCalls) > 0 { - var blocks []anthropic.ContentBlockParamUnion - if msg.Content != "" { - blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) - } - for _, tc := range msg.ToolCalls { - blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name)) - } - anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) - } else { - anthropicMessages = append(anthropicMessages, - anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)), - ) - } - case "tool": - anthropicMessages = append(anthropicMessages, - anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), - ) - } - } - - maxTokens := int64(4096) - if mt, ok := options["max_tokens"].(int); ok { - maxTokens = int64(mt) - } - - params := anthropic.MessageNewParams{ - Model: anthropic.Model(model), - Messages: anthropicMessages, - MaxTokens: maxTokens, - } - - if len(system) > 0 { - params.System = system - } - - if temp, ok := options["temperature"].(float64); ok { - params.Temperature = anthropic.Float(temp) - } - - if len(tools) > 0 { - params.Tools = translateToolsForClaude(tools) - } - - return params, nil -} - -func translateToolsForClaude(tools []ToolDefinition) []anthropic.ToolUnionParam { - result := make([]anthropic.ToolUnionParam, 0, len(tools)) - for _, t := range tools { - tool := anthropic.ToolParam{ - Name: t.Function.Name, - InputSchema: anthropic.ToolInputSchemaParam{ - Properties: t.Function.Parameters["properties"], - }, - } - if desc := t.Function.Description; desc != "" { - tool.Description = anthropic.String(desc) - } - if req, ok := t.Function.Parameters["required"].([]interface{}); ok { - required := make([]string, 0, len(req)) - for _, r := range req { - if s, ok := r.(string); ok { - required = append(required, s) - } - } - tool.InputSchema.Required = required - } - result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) - } - return result -} - -func parseClaudeResponse(resp *anthropic.Message) *LLMResponse { - var content string - var toolCalls []ToolCall - - for _, block := range resp.Content { - switch block.Type { - case "text": - tb := block.AsText() - content += tb.Text - case "tool_use": - tu := block.AsToolUse() - var args map[string]interface{} - if err := json.Unmarshal(tu.Input, &args); err != nil { - args = map[string]interface{}{"raw": string(tu.Input)} - } - toolCalls = append(toolCalls, ToolCall{ - ID: tu.ID, - Name: tu.Name, - Arguments: args, - }) - } - } - - finishReason := "stop" - switch resp.StopReason { - case anthropic.StopReasonToolUse: - finishReason = "tool_calls" - case anthropic.StopReasonMaxTokens: - finishReason = "length" - case anthropic.StopReasonEndTurn: - finishReason = "stop" - } - - return &LLMResponse{ - Content: content, - ToolCalls: toolCalls, - FinishReason: finishReason, - Usage: &UsageInfo{ - PromptTokens: int(resp.Usage.InputTokens), - CompletionTokens: int(resp.Usage.OutputTokens), - TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens), - }, - } -} - -func createClaudeTokenSource() func() (string, error) { - return func() (string, error) { - cred, err := auth.GetCredential("anthropic") - if err != nil { - return "", fmt.Errorf("loading auth credentials: %w", err) - } - if cred == nil { - return "", fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") - } - return cred.AccessToken, nil - } -} diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/claude_provider_test.go deleted file mode 100644 index bbad2d269..000000000 --- a/pkg/providers/claude_provider_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package providers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/anthropics/anthropic-sdk-go" - anthropicoption "github.com/anthropics/anthropic-sdk-go/option" -) - -func TestBuildClaudeParams_BasicMessage(t *testing.T) { - messages := []Message{ - {Role: "user", Content: "Hello"}, - } - params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{ - "max_tokens": 1024, - }) - if err != nil { - t.Fatalf("buildClaudeParams() error: %v", err) - } - if string(params.Model) != "claude-sonnet-4-5-20250929" { - t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929") - } - if params.MaxTokens != 1024 { - t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens) - } - if len(params.Messages) != 1 { - t.Fatalf("len(Messages) = %d, want 1", len(params.Messages)) - } -} - -func TestBuildClaudeParams_SystemMessage(t *testing.T) { - messages := []Message{ - {Role: "system", Content: "You are helpful"}, - {Role: "user", Content: "Hi"}, - } - params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) - if err != nil { - t.Fatalf("buildClaudeParams() error: %v", err) - } - if len(params.System) != 1 { - t.Fatalf("len(System) = %d, want 1", len(params.System)) - } - if params.System[0].Text != "You are helpful" { - t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful") - } - if len(params.Messages) != 1 { - t.Fatalf("len(Messages) = %d, want 1", len(params.Messages)) - } -} - -func TestBuildClaudeParams_ToolCallMessage(t *testing.T) { - messages := []Message{ - {Role: "user", Content: "What's the weather?"}, - { - Role: "assistant", - Content: "", - ToolCalls: []ToolCall{ - { - ID: "call_1", - Name: "get_weather", - Arguments: map[string]interface{}{"city": "SF"}, - }, - }, - }, - {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, - } - params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) - if err != nil { - t.Fatalf("buildClaudeParams() error: %v", err) - } - if len(params.Messages) != 3 { - t.Fatalf("len(Messages) = %d, want 3", len(params.Messages)) - } -} - -func TestBuildClaudeParams_WithTools(t *testing.T) { - tools := []ToolDefinition{ - { - Type: "function", - Function: ToolFunctionDefinition{ - Name: "get_weather", - Description: "Get weather for a city", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{"type": "string"}, - }, - "required": []interface{}{"city"}, - }, - }, - }, - } - params, err := buildClaudeParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{}) - if err != nil { - t.Fatalf("buildClaudeParams() error: %v", err) - } - if len(params.Tools) != 1 { - t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) - } -} - -func TestParseClaudeResponse_TextOnly(t *testing.T) { - resp := &anthropic.Message{ - Content: []anthropic.ContentBlockUnion{}, - Usage: anthropic.Usage{ - InputTokens: 10, - OutputTokens: 20, - }, - } - result := parseClaudeResponse(resp) - if result.Usage.PromptTokens != 10 { - t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens) - } - if result.Usage.CompletionTokens != 20 { - t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens) - } - if result.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") - } -} - -func TestParseClaudeResponse_StopReasons(t *testing.T) { - tests := []struct { - stopReason anthropic.StopReason - want string - }{ - {anthropic.StopReasonEndTurn, "stop"}, - {anthropic.StopReasonMaxTokens, "length"}, - {anthropic.StopReasonToolUse, "tool_calls"}, - } - for _, tt := range tests { - resp := &anthropic.Message{ - StopReason: tt.stopReason, - } - result := parseClaudeResponse(resp) - if result.FinishReason != tt.want { - t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want) - } - } -} - -func TestClaudeProvider_ChatRoundTrip(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/messages" { - http.Error(w, "not found", http.StatusNotFound) - return - } - if r.Header.Get("Authorization") != "Bearer test-token" { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - var reqBody map[string]interface{} - json.NewDecoder(r.Body).Decode(&reqBody) - - resp := map[string]interface{}{ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": reqBody["model"], - "stop_reason": "end_turn", - "content": []map[string]interface{}{ - {"type": "text", "text": "Hello! How can I help you?"}, - }, - "usage": map[string]interface{}{ - "input_tokens": 15, - "output_tokens": 8, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - provider := NewClaudeProvider("test-token") - provider.client = createAnthropicTestClient(server.URL, "test-token") - - messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024}) - if err != nil { - t.Fatalf("Chat() error: %v", err) - } - if resp.Content != "Hello! How can I help you?" { - t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I help you?") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - if resp.Usage.PromptTokens != 15 { - t.Errorf("PromptTokens = %d, want 15", resp.Usage.PromptTokens) - } -} - -func TestClaudeProvider_GetDefaultModel(t *testing.T) { - p := NewClaudeProvider("test-token") - if got := p.GetDefaultModel(); got != "claude-sonnet-4-5-20250929" { - t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4-5-20250929") - } -} - -func createAnthropicTestClient(baseURL, token string) *anthropic.Client { - c := anthropic.NewClient( - anthropicoption.WithAuthToken(token), - anthropicoption.WithBaseURL(baseURL), - ) - return &c -} diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go deleted file mode 100644 index 3463389a5..000000000 --- a/pkg/providers/codex_provider.go +++ /dev/null @@ -1,248 +0,0 @@ -package providers - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/openai/openai-go/v3" - "github.com/openai/openai-go/v3/option" - "github.com/openai/openai-go/v3/responses" - "github.com/sipeed/picoclaw/pkg/auth" -) - -type CodexProvider struct { - client *openai.Client - accountID string - tokenSource func() (string, string, error) -} - -func NewCodexProvider(token, accountID string) *CodexProvider { - opts := []option.RequestOption{ - option.WithBaseURL("https://chatgpt.com/backend-api/codex"), - option.WithAPIKey(token), - } - if accountID != "" { - opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID)) - } - client := openai.NewClient(opts...) - return &CodexProvider{ - client: &client, - accountID: accountID, - } -} - -func NewCodexProviderWithTokenSource(token, accountID string, tokenSource func() (string, string, error)) *CodexProvider { - p := NewCodexProvider(token, accountID) - p.tokenSource = tokenSource - return p -} - -func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - var opts []option.RequestOption - if p.tokenSource != nil { - tok, accID, err := p.tokenSource() - if err != nil { - return nil, fmt.Errorf("refreshing token: %w", err) - } - opts = append(opts, option.WithAPIKey(tok)) - if accID != "" { - opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accID)) - } - } - - params := buildCodexParams(messages, tools, model, options) - - resp, err := p.client.Responses.New(ctx, params, opts...) - if err != nil { - return nil, fmt.Errorf("codex API call: %w", err) - } - - return parseCodexResponse(resp), nil -} - -func (p *CodexProvider) GetDefaultModel() string { - return "gpt-4o" -} - -func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) responses.ResponseNewParams { - var inputItems responses.ResponseInputParam - var instructions string - - for _, msg := range messages { - switch msg.Role { - case "system": - instructions = msg.Content - case "user": - if msg.ToolCallID != "" { - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ - CallID: msg.ToolCallID, - Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } else { - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfMessage: &responses.EasyInputMessageParam{ - Role: responses.EasyInputMessageRoleUser, - Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } - case "assistant": - if len(msg.ToolCalls) > 0 { - if msg.Content != "" { - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfMessage: &responses.EasyInputMessageParam{ - Role: responses.EasyInputMessageRoleAssistant, - Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } - for _, tc := range msg.ToolCalls { - argsJSON, _ := json.Marshal(tc.Arguments) - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfFunctionCall: &responses.ResponseFunctionToolCallParam{ - CallID: tc.ID, - Name: tc.Name, - Arguments: string(argsJSON), - }, - }) - } - } else { - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfMessage: &responses.EasyInputMessageParam{ - Role: responses.EasyInputMessageRoleAssistant, - Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } - case "tool": - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ - CallID: msg.ToolCallID, - Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } - } - - params := responses.ResponseNewParams{ - Model: model, - Input: responses.ResponseNewParamsInputUnion{ - OfInputItemList: inputItems, - }, - Store: openai.Opt(false), - } - - if instructions != "" { - params.Instructions = openai.Opt(instructions) - } - - if maxTokens, ok := options["max_tokens"].(int); ok { - params.MaxOutputTokens = openai.Opt(int64(maxTokens)) - } - - if temp, ok := options["temperature"].(float64); ok { - params.Temperature = openai.Opt(temp) - } - - if len(tools) > 0 { - params.Tools = translateToolsForCodex(tools) - } - - return params -} - -func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { - result := make([]responses.ToolUnionParam, 0, len(tools)) - for _, t := range tools { - ft := responses.FunctionToolParam{ - Name: t.Function.Name, - Parameters: t.Function.Parameters, - Strict: openai.Opt(false), - } - if t.Function.Description != "" { - ft.Description = openai.Opt(t.Function.Description) - } - result = append(result, responses.ToolUnionParam{OfFunction: &ft}) - } - return result -} - -func parseCodexResponse(resp *responses.Response) *LLMResponse { - var content strings.Builder - var toolCalls []ToolCall - - for _, item := range resp.Output { - switch item.Type { - case "message": - for _, c := range item.Content { - if c.Type == "output_text" { - content.WriteString(c.Text) - } - } - case "function_call": - var args map[string]interface{} - if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil { - args = map[string]interface{}{"raw": item.Arguments} - } - toolCalls = append(toolCalls, ToolCall{ - ID: item.CallID, - Name: item.Name, - Arguments: args, - }) - } - } - - finishReason := "stop" - if len(toolCalls) > 0 { - finishReason = "tool_calls" - } - if resp.Status == "incomplete" { - finishReason = "length" - } - - var usage *UsageInfo - if resp.Usage.TotalTokens > 0 { - usage = &UsageInfo{ - PromptTokens: int(resp.Usage.InputTokens), - CompletionTokens: int(resp.Usage.OutputTokens), - TotalTokens: int(resp.Usage.TotalTokens), - } - } - - return &LLMResponse{ - Content: content.String(), - ToolCalls: toolCalls, - FinishReason: finishReason, - Usage: usage, - } -} - -func createCodexTokenSource() func() (string, string, error) { - return func() (string, string, error) { - cred, err := auth.GetCredential("openai") - if err != nil { - return "", "", fmt.Errorf("loading auth credentials: %w", err) - } - if cred == nil { - return "", "", fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai") - } - - if cred.AuthMethod == "oauth" && cred.NeedsRefresh() && cred.RefreshToken != "" { - oauthCfg := auth.OpenAIOAuthConfig() - refreshed, err := auth.RefreshAccessToken(cred, oauthCfg) - if err != nil { - return "", "", fmt.Errorf("refreshing token: %w", err) - } - if err := auth.SetCredential("openai", refreshed); err != nil { - return "", "", fmt.Errorf("saving refreshed token: %w", err) - } - return refreshed.AccessToken, refreshed.AccountID, nil - } - - return cred.AccessToken, cred.AccountID, nil - } -} diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go deleted file mode 100644 index 605183d5e..000000000 --- a/pkg/providers/codex_provider_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package providers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/openai/openai-go/v3" - openaiopt "github.com/openai/openai-go/v3/option" - "github.com/openai/openai-go/v3/responses" -) - -func TestBuildCodexParams_BasicMessage(t *testing.T) { - messages := []Message{ - {Role: "user", Content: "Hello"}, - } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{ - "max_tokens": 2048, - }) - if params.Model != "gpt-4o" { - t.Errorf("Model = %q, want %q", params.Model, "gpt-4o") - } -} - -func TestBuildCodexParams_SystemAsInstructions(t *testing.T) { - messages := []Message{ - {Role: "system", Content: "You are helpful"}, - {Role: "user", Content: "Hi"}, - } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) - if !params.Instructions.Valid() { - t.Fatal("Instructions should be set") - } - if params.Instructions.Or("") != "You are helpful" { - t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), "You are helpful") - } -} - -func TestBuildCodexParams_ToolCallConversation(t *testing.T) { - messages := []Message{ - {Role: "user", Content: "What's the weather?"}, - { - Role: "assistant", - ToolCalls: []ToolCall{ - {ID: "call_1", Name: "get_weather", Arguments: map[string]interface{}{"city": "SF"}}, - }, - }, - {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, - } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) - if params.Input.OfInputItemList == nil { - t.Fatal("Input.OfInputItemList should not be nil") - } - if len(params.Input.OfInputItemList) != 3 { - t.Errorf("len(Input items) = %d, want 3", len(params.Input.OfInputItemList)) - } -} - -func TestBuildCodexParams_WithTools(t *testing.T) { - tools := []ToolDefinition{ - { - Type: "function", - Function: ToolFunctionDefinition{ - Name: "get_weather", - Description: "Get weather", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{"type": "string"}, - }, - }, - }, - }, - } - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}) - if len(params.Tools) != 1 { - t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) - } - if params.Tools[0].OfFunction == nil { - t.Fatal("Tool should be a function tool") - } - if params.Tools[0].OfFunction.Name != "get_weather" { - t.Errorf("Tool name = %q, want %q", params.Tools[0].OfFunction.Name, "get_weather") - } -} - -func TestBuildCodexParams_StoreIsFalse(t *testing.T) { - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}) - if !params.Store.Valid() || params.Store.Or(true) != false { - t.Error("Store should be explicitly set to false") - } -} - -func TestParseCodexResponse_TextOutput(t *testing.T) { - respJSON := `{ - "id": "resp_test", - "object": "response", - "status": "completed", - "output": [ - { - "id": "msg_1", - "type": "message", - "role": "assistant", - "status": "completed", - "content": [ - {"type": "output_text", "text": "Hello there!"} - ] - } - ], - "usage": { - "input_tokens": 10, - "output_tokens": 5, - "total_tokens": 15, - "input_tokens_details": {"cached_tokens": 0}, - "output_tokens_details": {"reasoning_tokens": 0} - } - }` - - var resp responses.Response - if err := json.Unmarshal([]byte(respJSON), &resp); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - result := parseCodexResponse(&resp) - if result.Content != "Hello there!" { - t.Errorf("Content = %q, want %q", result.Content, "Hello there!") - } - if result.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") - } - if result.Usage.TotalTokens != 15 { - t.Errorf("TotalTokens = %d, want 15", result.Usage.TotalTokens) - } -} - -func TestParseCodexResponse_FunctionCall(t *testing.T) { - respJSON := `{ - "id": "resp_test", - "object": "response", - "status": "completed", - "output": [ - { - "id": "fc_1", - "type": "function_call", - "call_id": "call_abc", - "name": "get_weather", - "arguments": "{\"city\":\"SF\"}", - "status": "completed" - } - ], - "usage": { - "input_tokens": 10, - "output_tokens": 8, - "total_tokens": 18, - "input_tokens_details": {"cached_tokens": 0}, - "output_tokens_details": {"reasoning_tokens": 0} - } - }` - - var resp responses.Response - if err := json.Unmarshal([]byte(respJSON), &resp); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - result := parseCodexResponse(&resp) - if len(result.ToolCalls) != 1 { - t.Fatalf("len(ToolCalls) = %d, want 1", len(result.ToolCalls)) - } - tc := result.ToolCalls[0] - if tc.Name != "get_weather" { - t.Errorf("ToolCall.Name = %q, want %q", tc.Name, "get_weather") - } - if tc.ID != "call_abc" { - t.Errorf("ToolCall.ID = %q, want %q", tc.ID, "call_abc") - } - if tc.Arguments["city"] != "SF" { - t.Errorf("ToolCall.Arguments[city] = %v, want SF", tc.Arguments["city"]) - } - if result.FinishReason != "tool_calls" { - t.Errorf("FinishReason = %q, want %q", result.FinishReason, "tool_calls") - } -} - -func TestCodexProvider_ChatRoundTrip(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/responses" { - http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) - return - } - if r.Header.Get("Authorization") != "Bearer test-token" { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Header.Get("Chatgpt-Account-Id") != "acc-123" { - http.Error(w, "missing account id", http.StatusBadRequest) - return - } - - resp := map[string]interface{}{ - "id": "resp_test", - "object": "response", - "status": "completed", - "output": []map[string]interface{}{ - { - "id": "msg_1", - "type": "message", - "role": "assistant", - "status": "completed", - "content": []map[string]interface{}{ - {"type": "output_text", "text": "Hi from Codex!"}, - }, - }, - }, - "usage": map[string]interface{}{ - "input_tokens": 12, - "output_tokens": 6, - "total_tokens": 18, - "input_tokens_details": map[string]interface{}{"cached_tokens": 0}, - "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0}, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - provider := NewCodexProvider("test-token", "acc-123") - provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") - - messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"max_tokens": 1024}) - if err != nil { - t.Fatalf("Chat() error: %v", err) - } - if resp.Content != "Hi from Codex!" { - t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - if resp.Usage.TotalTokens != 18 { - t.Errorf("TotalTokens = %d, want 18", resp.Usage.TotalTokens) - } -} - -func TestCodexProvider_GetDefaultModel(t *testing.T) { - p := NewCodexProvider("test-token", "") - if got := p.GetDefaultModel(); got != "gpt-4o" { - t.Errorf("GetDefaultModel() = %q, want %q", got, "gpt-4o") - } -} - -func createOpenAITestClient(baseURL, token, accountID string) *openai.Client { - opts := []openaiopt.RequestOption{ - openaiopt.WithBaseURL(baseURL), - openaiopt.WithAPIKey(token), - } - if accountID != "" { - opts = append(opts, openaiopt.WithHeader("Chatgpt-Account-Id", accountID)) - } - c := openai.NewClient(opts...) - return &c -} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go deleted file mode 100644 index 6fcbd3055..000000000 --- a/pkg/providers/http_provider.go +++ /dev/null @@ -1,423 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package providers - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/sipeed/picoclaw/pkg/auth" - "github.com/sipeed/picoclaw/pkg/config" -) - -type HTTPProvider struct { - apiKey string - apiBase string - httpClient *http.Client -} - -func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { - client := &http.Client{ - Timeout: 0, - } - - if proxy != "" { - proxyURL, err := url.Parse(proxy) - if err == nil { - client.Transport = &http.Transport{ - Proxy: http.ProxyURL(proxyURL), - } - } - } - - return &HTTPProvider{ - apiKey: apiKey, - apiBase: strings.TrimRight(apiBase, "/"), - httpClient: client, - } -} - -func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - if p.apiBase == "" { - return nil, fmt.Errorf("API base not configured") - } - - // Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5) - if idx := strings.Index(model, "/"); idx != -1 { - prefix := model[:idx] - if prefix == "moonshot" || prefix == "nvidia" { - model = model[idx+1:] - } - } - - requestBody := map[string]interface{}{ - "model": model, - "messages": messages, - } - - if len(tools) > 0 { - requestBody["tools"] = tools - requestBody["tool_choice"] = "auto" - } - - if maxTokens, ok := options["max_tokens"].(int); ok { - lowerModel := strings.ToLower(model) - if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") { - requestBody["max_completion_tokens"] = maxTokens - } else { - requestBody["max_tokens"] = maxTokens - } - } - - if temperature, ok := options["temperature"].(float64); ok { - lowerModel := strings.ToLower(model) - // Kimi k2 models only support temperature=1 - if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") { - requestBody["temperature"] = 1.0 - } else { - requestBody["temperature"] = temperature - } - } - - jsonData, err := json.Marshal(requestBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - if p.apiKey != "" { - req.Header.Set("Authorization", "Bearer "+p.apiKey) - } - - resp, err := p.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body)) - } - - return p.parseResponse(body) -} - -func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) { - var apiResponse struct { - Choices []struct { - Message struct { - Content string `json:"content"` - ToolCalls []struct { - ID string `json:"id"` - Type string `json:"type"` - Function *struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` - } `json:"tool_calls"` - } `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage *UsageInfo `json:"usage"` - } - - if err := json.Unmarshal(body, &apiResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - if len(apiResponse.Choices) == 0 { - return &LLMResponse{ - Content: "", - FinishReason: "stop", - }, nil - } - - choice := apiResponse.Choices[0] - - toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) - for _, tc := range choice.Message.ToolCalls { - arguments := make(map[string]interface{}) - name := "" - - // Handle OpenAI format with nested function object - if tc.Type == "function" && tc.Function != nil { - name = tc.Function.Name - if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { - arguments["raw"] = tc.Function.Arguments - } - } - } else if tc.Function != nil { - // Legacy format without type field - name = tc.Function.Name - if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { - arguments["raw"] = tc.Function.Arguments - } - } - } - - toolCalls = append(toolCalls, ToolCall{ - ID: tc.ID, - Name: name, - Arguments: arguments, - }) - } - - return &LLMResponse{ - Content: choice.Message.Content, - ToolCalls: toolCalls, - FinishReason: choice.FinishReason, - Usage: apiResponse.Usage, - }, nil -} - -func (p *HTTPProvider) GetDefaultModel() string { - return "" -} - -func createClaudeAuthProvider() (LLMProvider, error) { - cred, err := auth.GetCredential("anthropic") - if err != nil { - return nil, fmt.Errorf("loading auth credentials: %w", err) - } - if cred == nil { - return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") - } - return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil -} - -func createCodexAuthProvider() (LLMProvider, error) { - cred, err := auth.GetCredential("openai") - if err != nil { - return nil, fmt.Errorf("loading auth credentials: %w", err) - } - if cred == nil { - return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai") - } - return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil -} - -func CreateProvider(cfg *config.Config) (LLMProvider, error) { - model := cfg.Agents.Defaults.Model - providerName := strings.ToLower(cfg.Agents.Defaults.Provider) - - var apiKey, apiBase, proxy string - - lowerModel := strings.ToLower(model) - - // First, try to use explicitly configured provider - if providerName != "" { - switch providerName { - case "groq": - if cfg.Providers.Groq.APIKey != "" { - apiKey = cfg.Providers.Groq.APIKey - apiBase = cfg.Providers.Groq.APIBase - if apiBase == "" { - apiBase = "https://api.groq.com/openai/v1" - } - } - case "openai", "gpt": - if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { - if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - return createCodexAuthProvider() - } - apiKey = cfg.Providers.OpenAI.APIKey - apiBase = cfg.Providers.OpenAI.APIBase - if apiBase == "" { - apiBase = "https://api.openai.com/v1" - } - } - case "anthropic", "claude": - if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { - return createClaudeAuthProvider() - } - apiKey = cfg.Providers.Anthropic.APIKey - apiBase = cfg.Providers.Anthropic.APIBase - if apiBase == "" { - apiBase = "https://api.anthropic.com/v1" - } - } - case "openrouter": - if cfg.Providers.OpenRouter.APIKey != "" { - apiKey = cfg.Providers.OpenRouter.APIKey - if cfg.Providers.OpenRouter.APIBase != "" { - apiBase = cfg.Providers.OpenRouter.APIBase - } else { - apiBase = "https://openrouter.ai/api/v1" - } - } - case "zhipu", "glm": - if cfg.Providers.Zhipu.APIKey != "" { - apiKey = cfg.Providers.Zhipu.APIKey - apiBase = cfg.Providers.Zhipu.APIBase - if apiBase == "" { - apiBase = "https://open.bigmodel.cn/api/paas/v4" - } - } - case "gemini", "google": - if cfg.Providers.Gemini.APIKey != "" { - apiKey = cfg.Providers.Gemini.APIKey - apiBase = cfg.Providers.Gemini.APIBase - if apiBase == "" { - apiBase = "https://generativelanguage.googleapis.com/v1beta" - } - } - case "vllm": - if cfg.Providers.VLLM.APIBase != "" { - apiKey = cfg.Providers.VLLM.APIKey - apiBase = cfg.Providers.VLLM.APIBase - } - case "shengsuanyun": - if cfg.Providers.ShengSuanYun.APIKey != "" { - apiKey = cfg.Providers.ShengSuanYun.APIKey - apiBase = cfg.Providers.ShengSuanYun.APIBase - if apiBase == "" { - apiBase = "https://router.shengsuanyun.com/api/v1" - } - } - case "claude-cli", "claudecode", "claude-code": - workspace := cfg.Agents.Defaults.Workspace - if workspace == "" { - workspace = "." - } - return NewClaudeCliProvider(workspace), nil - case "deepseek": - if cfg.Providers.DeepSeek.APIKey != "" { - apiKey = cfg.Providers.DeepSeek.APIKey - apiBase = cfg.Providers.DeepSeek.APIBase - if apiBase == "" { - apiBase = "https://api.deepseek.com/v1" - } - if model != "deepseek-chat" && model != "deepseek-reasoner" { - model = "deepseek-chat" - } - } - } - } - - // Fallback: detect provider from model name - if apiKey == "" && apiBase == "" { - switch { - case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "": - apiKey = cfg.Providers.Moonshot.APIKey - apiBase = cfg.Providers.Moonshot.APIBase - proxy = cfg.Providers.Moonshot.Proxy - if apiBase == "" { - apiBase = "https://api.moonshot.cn/v1" - } - - case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"): - apiKey = cfg.Providers.OpenRouter.APIKey - proxy = cfg.Providers.OpenRouter.Proxy - if cfg.Providers.OpenRouter.APIBase != "" { - apiBase = cfg.Providers.OpenRouter.APIBase - } else { - apiBase = "https://openrouter.ai/api/v1" - } - - case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { - return createClaudeAuthProvider() - } - apiKey = cfg.Providers.Anthropic.APIKey - apiBase = cfg.Providers.Anthropic.APIBase - proxy = cfg.Providers.Anthropic.Proxy - if apiBase == "" { - apiBase = "https://api.anthropic.com/v1" - } - - case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): - if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - return createCodexAuthProvider() - } - apiKey = cfg.Providers.OpenAI.APIKey - apiBase = cfg.Providers.OpenAI.APIBase - proxy = cfg.Providers.OpenAI.Proxy - if apiBase == "" { - apiBase = "https://api.openai.com/v1" - } - - case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "": - apiKey = cfg.Providers.Gemini.APIKey - apiBase = cfg.Providers.Gemini.APIBase - proxy = cfg.Providers.Gemini.Proxy - if apiBase == "" { - apiBase = "https://generativelanguage.googleapis.com/v1beta" - } - - case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "": - apiKey = cfg.Providers.Zhipu.APIKey - apiBase = cfg.Providers.Zhipu.APIBase - proxy = cfg.Providers.Zhipu.Proxy - if apiBase == "" { - apiBase = "https://open.bigmodel.cn/api/paas/v4" - } - - case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "": - apiKey = cfg.Providers.Groq.APIKey - apiBase = cfg.Providers.Groq.APIBase - proxy = cfg.Providers.Groq.Proxy - if apiBase == "" { - apiBase = "https://api.groq.com/openai/v1" - } - - case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "": - apiKey = cfg.Providers.Nvidia.APIKey - apiBase = cfg.Providers.Nvidia.APIBase - proxy = cfg.Providers.Nvidia.Proxy - if apiBase == "" { - apiBase = "https://integrate.api.nvidia.com/v1" - } - - case cfg.Providers.VLLM.APIBase != "": - apiKey = cfg.Providers.VLLM.APIKey - apiBase = cfg.Providers.VLLM.APIBase - proxy = cfg.Providers.VLLM.Proxy - - default: - if cfg.Providers.OpenRouter.APIKey != "" { - apiKey = cfg.Providers.OpenRouter.APIKey - proxy = cfg.Providers.OpenRouter.Proxy - if cfg.Providers.OpenRouter.APIBase != "" { - apiBase = cfg.Providers.OpenRouter.APIBase - } else { - apiBase = "https://openrouter.ai/api/v1" - } - } else { - return nil, fmt.Errorf("no API key configured for model: %s", model) - } - } - } - - if apiKey == "" && !strings.HasPrefix(model, "bedrock/") { - return nil, fmt.Errorf("no API key configured for provider (model: %s)", model) - } - - if apiBase == "" { - return nil, fmt.Errorf("no API base configured for provider (model: %s)", model) - } - - return NewHTTPProvider(apiKey, apiBase, proxy), nil -} diff --git a/pkg/providers/types.go b/pkg/providers/types.go index 88b62e975..56f42290c 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -1,20 +1,21 @@ package providers -import "context" +import ( + "context" -type ToolCall struct { - ID string `json:"id"` - Type string `json:"type,omitempty"` - Function *FunctionCall `json:"function,omitempty"` - Name string `json:"name,omitempty"` - Arguments map[string]interface{} `json:"arguments,omitempty"` -} + "github.com/sipeed/picoclaw/pkg/messages" +) -type FunctionCall struct { - Name string `json:"name"` - Arguments string `json:"arguments"` -} +// Type aliases: canonical types now live in pkg/messages. +// These aliases maintain backward compatibility during migration. +type ToolCall = messages.ToolCall +type FunctionCall = messages.FunctionCall +type UsageInfo = messages.UsageInfo +type Message = messages.Message +type ToolDefinition = messages.ToolDefinition +type ToolFunctionDefinition = messages.ToolFunctionDefinition +// LLMResponse is the response from an LLM provider API call. type LLMResponse struct { Content string `json:"content"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` @@ -22,31 +23,8 @@ type LLMResponse struct { Usage *UsageInfo `json:"usage,omitempty"` } -type UsageInfo struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` -} - -type Message struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` -} - +// LLMProvider is the interface for LLM provider implementations. type LLMProvider interface { Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) GetDefaultModel() string } - -type ToolDefinition struct { - Type string `json:"type"` - Function ToolFunctionDefinition `json:"function"` -} - -type ToolFunctionDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` -}