Detailed changes
@@ -19,6 +19,7 @@ import (
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/llm/tools"
+ "github.com/charmbracelet/crush/internal/log"
"github.com/charmbracelet/crush/internal/message"
)
@@ -77,6 +78,12 @@ func createAnthropicClient(opts providerClientOptions, tp AnthropicClientType) a
} else if hasBearerAuth {
slog.Debug("Skipping X-Api-Key header because Authorization header is provided")
}
+
+ if config.Get().Options.Debug {
+ httpClient := log.NewHTTPClient()
+ anthropicClientOptions = append(anthropicClientOptions, option.WithHTTPClient(httpClient))
+ }
+
switch tp {
case AnthropicClientTypeBedrock:
anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
@@ -271,17 +278,11 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
}
func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
- cfg := config.Get()
-
attempts := 0
for {
attempts++
// Prepare messages on each attempt in case max_tokens was adjusted
preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
- if cfg.Options.Debug {
- jsonData, _ := json.Marshal(preparedMessages)
- slog.Debug("Prepared messages", "messages", string(jsonData))
- }
var opts []option.RequestOption
if a.isThinkingEnabled() {
@@ -294,7 +295,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
)
// If there is an error we are going to see if we can retry the call
if err != nil {
- slog.Error("Error in Anthropic API call", "error", err)
+ slog.Error("Anthropic API error", "error", err.Error(), "attempt", attempts, "max_retries", maxRetries)
retry, after, retryErr := a.shouldRetry(attempts, err)
if retryErr != nil {
return nil, retryErr
@@ -327,7 +328,6 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
}
func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
- cfg := config.Get()
attempts := 0
eventChan := make(chan ProviderEvent)
go func() {
@@ -335,10 +335,6 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
attempts++
// Prepare messages on each attempt in case max_tokens was adjusted
preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
- if cfg.Options.Debug {
- jsonData, _ := json.Marshal(preparedMessages)
- slog.Debug("Prepared messages", "messages", string(jsonData))
- }
var opts []option.RequestOption
if a.isThinkingEnabled() {
@@ -1,6 +1,8 @@
package provider
import (
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/log"
"github.com/openai/openai-go"
"github.com/openai/openai-go/azure"
"github.com/openai/openai-go/option"
@@ -22,6 +24,11 @@ func newAzureClient(opts providerClientOptions) AzureClient {
azure.WithEndpoint(opts.baseURL, apiVersion),
}
+ if config.Get().Options.Debug {
+ httpClient := log.NewHTTPClient()
+ reqOpts = append(reqOpts, option.WithHTTPClient(httpClient))
+ }
+
reqOpts = append(reqOpts, azure.WithAPIKey(opts.apiKey))
base := &openaiClient{
providerOptions: opts,
@@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/llm/tools"
+ "github.com/charmbracelet/crush/internal/log"
"github.com/charmbracelet/crush/internal/message"
"github.com/google/uuid"
"google.golang.org/genai"
@@ -39,7 +40,14 @@ func newGeminiClient(opts providerClientOptions) GeminiClient {
}
func createGeminiClient(opts providerClientOptions) (*genai.Client, error) {
- client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: opts.apiKey, Backend: genai.BackendGeminiAPI})
+ cc := &genai.ClientConfig{
+ APIKey: opts.apiKey,
+ Backend: genai.BackendGeminiAPI,
+ }
+ if config.Get().Options.Debug {
+ cc.HTTPClient = log.NewHTTPClient()
+ }
+ client, err := genai.NewClient(context.Background(), cc)
if err != nil {
return nil, err
}
@@ -166,10 +174,6 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
geminiMessages := g.convertMessages(messages)
model := g.providerOptions.model(g.providerOptions.modelType)
cfg := config.Get()
- if cfg.Options.Debug {
- jsonData, _ := json.Marshal(geminiMessages)
- slog.Debug("Prepared messages", "messages", string(jsonData))
- }
modelConfig := cfg.Models[config.SelectedModelTypeLarge]
if g.providerOptions.modelType == config.SelectedModelTypeSmall {
@@ -266,10 +270,6 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
model := g.providerOptions.model(g.providerOptions.modelType)
cfg := config.Get()
- if cfg.Options.Debug {
- jsonData, _ := json.Marshal(geminiMessages)
- slog.Debug("Prepared messages", "messages", string(jsonData))
- }
modelConfig := cfg.Models[config.SelectedModelTypeLarge]
if g.providerOptions.modelType == config.SelectedModelTypeSmall {
@@ -2,7 +2,6 @@ package provider
import (
"context"
- "encoding/json"
"errors"
"fmt"
"io"
@@ -13,6 +12,7 @@ import (
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/llm/tools"
+ "github.com/charmbracelet/crush/internal/log"
"github.com/charmbracelet/crush/internal/message"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
@@ -46,6 +46,11 @@ func createOpenAIClient(opts providerClientOptions) openai.Client {
}
}
+ if config.Get().Options.Debug {
+ httpClient := log.NewHTTPClient()
+ openaiClientOptions = append(openaiClientOptions, option.WithHTTPClient(httpClient))
+ }
+
for key, value := range opts.extraHeaders {
openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
}
@@ -250,11 +255,6 @@ func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessagePar
func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
- cfg := config.Get()
- if cfg.Options.Debug {
- jsonData, _ := json.Marshal(params)
- slog.Debug("Prepared messages", "messages", string(jsonData))
- }
attempts := 0
for {
attempts++
@@ -311,12 +311,6 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
IncludeUsage: openai.Bool(true),
}
- cfg := config.Get()
- if cfg.Options.Debug {
- jsonData, _ := json.Marshal(params)
- slog.Debug("Prepared messages", "messages", string(jsonData))
- }
-
attempts := 0
eventChan := make(chan ProviderEvent)
@@ -420,11 +414,6 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
err := openaiStream.Err()
if err == nil || errors.Is(err, io.EOF) {
- if cfg.Options.Debug {
- jsonData, _ := json.Marshal(acc.ChatCompletion)
- slog.Debug("Response", "messages", string(jsonData))
- }
-
if len(acc.Choices) == 0 {
eventChan <- ProviderEvent{
Type: EventError,
@@ -525,7 +514,7 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error)
slog.Warn("Retry-After header", "values", retryAfterValues)
}
} else {
- slog.Warn("OpenAI API error", "error", err.Error())
+ slog.Error("OpenAI API error", "error", err.Error(), "attempt", attempts, "max_retries", maxRetries)
}
backoffMs := 2000 * (1 << (attempts - 1))
@@ -5,6 +5,8 @@ import (
"log/slog"
"strings"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/log"
"google.golang.org/genai"
)
@@ -13,11 +15,15 @@ type VertexAIClient ProviderClient
func newVertexAIClient(opts providerClientOptions) VertexAIClient {
project := opts.extraParams["project"]
location := opts.extraParams["location"]
- client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
+ cc := &genai.ClientConfig{
Project: project,
Location: location,
Backend: genai.BackendVertexAI,
- })
+ }
+ if config.Get().Options.Debug {
+ cc.HTTPClient = log.NewHTTPClient()
+ }
+ client, err := genai.NewClient(context.Background(), cc)
if err != nil {
slog.Error("Failed to create VertexAI client", "error", err)
return nil
@@ -0,0 +1,125 @@
+package log
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// NewHTTPClient creates an HTTP client with debug logging enabled when debug mode is on.
+func NewHTTPClient() *http.Client {
+ if !slog.Default().Enabled(context.TODO(), slog.LevelDebug) {
+ return http.DefaultClient
+ }
+ return &http.Client{
+ Transport: &HTTPRoundTripLogger{
+ Transport: http.DefaultTransport,
+ },
+ }
+}
+
+// HTTPRoundTripLogger is an http.RoundTripper that logs requests and responses.
+type HTTPRoundTripLogger struct {
+ Transport http.RoundTripper
+}
+
+// RoundTrip implements http.RoundTripper interface with logging.
+func (h *HTTPRoundTripLogger) RoundTrip(req *http.Request) (*http.Response, error) {
+ var err error
+ var save io.ReadCloser
+ save, req.Body, err = drainBody(req.Body)
+ if err != nil {
+ slog.Error(
+ "HTTP request failed",
+ "method", req.Method,
+ "url", req.URL,
+ "error", err,
+ )
+ return nil, err
+ }
+
+ slog.Debug(
+ "HTTP Request",
+ "method", req.Method,
+ "url", req.URL,
+ "body", bodyToString(save),
+ )
+
+ start := time.Now()
+ resp, err := h.Transport.RoundTrip(req)
+ duration := time.Since(start)
+ if err != nil {
+ slog.Error(
+ "HTTP request failed",
+ "method", req.Method,
+ "url", req.URL,
+ "duration_ms", duration.Milliseconds(),
+ "error", err,
+ )
+ return resp, err
+ }
+
+ save, resp.Body, err = drainBody(resp.Body)
+ slog.Debug(
+ "HTTP Response",
+ "status_code", resp.StatusCode,
+ "status", resp.Status,
+ "headers", formatHeaders(resp.Header),
+ "body", bodyToString(save),
+ "content_length", resp.ContentLength,
+ "duration_ms", duration.Milliseconds(),
+ "error", err,
+ )
+ return resp, err
+}
+
+func bodyToString(body io.ReadCloser) string {
+ src, err := io.ReadAll(body)
+ if err != nil {
+ slog.Error("Failed to read body", "error", err)
+ return ""
+ }
+ var b bytes.Buffer
+ if json.Compact(&b, bytes.TrimSpace(src)) != nil {
+ // not json probably
+ return string(src)
+ }
+ return b.String()
+}
+
+// formatHeaders formats HTTP headers for logging, filtering out sensitive information.
+func formatHeaders(headers http.Header) map[string][]string {
+ filtered := make(map[string][]string)
+ for key, values := range headers {
+ lowerKey := strings.ToLower(key)
+ // Filter out sensitive headers
+ if strings.Contains(lowerKey, "authorization") ||
+ strings.Contains(lowerKey, "api-key") ||
+ strings.Contains(lowerKey, "token") ||
+ strings.Contains(lowerKey, "secret") {
+ filtered[key] = []string{"[REDACTED]"}
+ } else {
+ filtered[key] = values
+ }
+ }
+ return filtered
+}
+
+func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) {
+ if b == nil || b == http.NoBody {
+ return http.NoBody, http.NoBody, nil
+ }
+ var buf bytes.Buffer
+ if _, err = buf.ReadFrom(b); err != nil {
+ return nil, b, err
+ }
+ if err = b.Close(); err != nil {
+ return nil, b, err
+ }
+ return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil
+}
@@ -0,0 +1,73 @@
+package log
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestHTTPRoundTripLogger(t *testing.T) {
+ // Create a test server that returns a 500 error
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("X-Custom-Header", "test-value")
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte(`{"error": "Internal server error", "code": 500}`))
+ }))
+ defer server.Close()
+
+ // Create HTTP client with logging
+ client := NewHTTPClient()
+
+ // Make a request
+ req, err := http.NewRequestWithContext(
+ t.Context(),
+ http.MethodPost,
+ server.URL,
+ strings.NewReader(`{"test": "data"}`),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer secret-token")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ // Verify response
+ if resp.StatusCode != http.StatusInternalServerError {
+ t.Errorf("Expected status code 500, got %d", resp.StatusCode)
+ }
+}
+
+func TestFormatHeaders(t *testing.T) {
+ headers := http.Header{
+ "Content-Type": []string{"application/json"},
+ "Authorization": []string{"Bearer secret-token"},
+ "X-API-Key": []string{"api-key-123"},
+ "User-Agent": []string{"test-agent"},
+ }
+
+ formatted := formatHeaders(headers)
+
+ // Check that sensitive headers are redacted
+ if formatted["Authorization"][0] != "[REDACTED]" {
+ t.Error("Authorization header should be redacted")
+ }
+ if formatted["X-API-Key"][0] != "[REDACTED]" {
+ t.Error("X-API-Key header should be redacted")
+ }
+
+ // Check that non-sensitive headers are preserved
+ if formatted["Content-Type"][0] != "application/json" {
+ t.Error("Content-Type header should be preserved")
+ }
+ if formatted["User-Agent"][0] != "test-agent" {
+ t.Error("User-Agent header should be preserved")
+ }
+}