mirror of
https://github.com/charmbracelet/crush.git
synced 2025-08-02 05:20:46 +03:00
feat: debug logs request response details (#407)
* feat: debug logs request response details closes #399 * fix: lint * refactor: improvements * fix: improvements * fix: test * fix: centralize body parsing * fix: compatct
This commit is contained in:
committed by
GitHub
parent
cd3ef8dbd4
commit
0e52ccd26a
@@ -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
|
||||
|
||||
125
internal/log/http.go
Normal file
125
internal/log/http.go
Normal file
@@ -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
|
||||
}
|
||||
73
internal/log/http_test.go
Normal file
73
internal/log/http_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user