diff --git a/go.mod b/go.mod index 16e5cf7..0c9b1f3 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/open-code-review/open-code-review go 1.25.0 require ( + github.com/anthropics/anthropic-sdk-go v1.47.0 github.com/bmatcuk/doublestar/v4 v4.10.0 + github.com/openai/openai-go/v3 v3.39.0 github.com/pkoukk/tiktoken-go v0.1.8 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 @@ -17,6 +19,8 @@ require ( ) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dlclark/regexp2 v1.10.0 // indirect @@ -24,10 +28,19 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/invopop/jsonschema v0.14.0 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect + github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index 1f8f50e..c28e891 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ +github.com/anthropics/anthropic-sdk-go v1.47.0 h1:p1F48S/5UAGK3h2NzvZP8rqKnZqB7RkyYvOEM8dOEaQ= +github.com/anthropics/anthropic-sdk-go v1.47.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -8,6 +14,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -21,12 +29,30 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= +github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/openai/openai-go/v3 v3.39.0 h1:WgLGgMOOdQDkZyo8YIhzUNXRXlEc+OJfU4EKP5Qp6AA= +github.com/openai/openai-go/v3 v3.39.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo= github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI= +github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -53,8 +79,12 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= @@ -69,5 +99,7 @@ google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/diff/relocation_test.go b/internal/diff/relocation_test.go index ce5ca5a..bd152fd 100644 --- a/internal/diff/relocation_test.go +++ b/internal/diff/relocation_test.go @@ -3,7 +3,6 @@ package diff import ( "context" "errors" - "net/http" "testing" "github.com/open-code-review/open-code-review/internal/config/template" @@ -16,28 +15,15 @@ type mockLLMClient struct { err error } -func (m *mockLLMClient) Completions(req llm.ChatRequest) (*llm.ChatResponse, error) { - return m.response, m.err -} - func (m *mockLLMClient) CompletionsWithCtx(_ context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { return m.response, m.err } -func (m *mockLLMClient) StreamCompletion(req llm.ChatRequest, cb func(chunk []byte) error) error { - return m.err -} - -func (m *mockLLMClient) StreamCompletionWithCtx(_ context.Context, req llm.ChatRequest, cb func(chunk []byte) error) error { - return m.err -} - func newMockResponse(content string) *llm.ChatResponse { return &llm.ChatResponse{ Choices: []llm.Choice{ {Message: llm.ResponseMessage{Role: "assistant", Content: &content}}, }, - Headers: http.Header{}, } } diff --git a/internal/llm/client.go b/internal/llm/client.go index 2217bc4..dc70e37 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -3,25 +3,21 @@ package llm import ( - "bufio" - "bytes" "context" "encoding/json" "fmt" - "io" - "math/rand" - "net/http" "strings" "sync" "time" + anthropic "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + openai "github.com/openai/openai-go/v3" + openaiopt "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/shared" tiktoken "github.com/pkoukk/tiktoken-go" - - "github.com/open-code-review/open-code-review/internal/stdout" ) -const maxRetries = 10 // Maximum number of retry attempts with exponential backoff. - var AppVersion = "dev" func userAgent(provider string) string { @@ -34,10 +30,7 @@ func userAgent(provider string) string { // LLMClient is the unified interface for all LLM protocol implementations. type LLMClient interface { - Completions(req ChatRequest) (*ChatResponse, error) CompletionsWithCtx(ctx context.Context, req ChatRequest) (*ChatResponse, error) - StreamCompletion(req ChatRequest, cb func(chunk []byte) error) error - StreamCompletionWithCtx(ctx context.Context, req ChatRequest, cb func(chunk []byte) error) error } // --- Shared data types --- @@ -144,11 +137,10 @@ type ResponseMessage struct { // ChatResponse is the parsed result of a completion request. type ChatResponse struct { - ID string `json:"-"` - Model string `json:"-"` - Choices []Choice `json:"-"` - Headers http.Header `json:"-"` // Raw response headers (may contain session IDs, etc.) - Usage *UsageInfo `json:"-"` // Token usage extracted from API response + ID string `json:"-"` + Model string `json:"-"` + Choices []Choice `json:"-"` + Usage *UsageInfo `json:"-"` // Token usage extracted from API response } // Content extracts the text content from the first choice, falling back to reasoning content. @@ -279,10 +271,10 @@ func encodingForModel(modelName string) string { // --- OpenAIClient --- -// OpenAIClient sends requests to an OpenAI-compatible chat completion API. +// OpenAIClient sends requests to an OpenAI-compatible chat completion API using the official SDK. type OpenAIClient struct { - cfg ClientConfig - client *http.Client + cfg ClientConfig + sdk openai.Client } // NewOpenAIClient creates a new OpenAI-compatible LLM client. @@ -294,34 +286,30 @@ func NewOpenAIClient(cfg ClientConfig) *OpenAIClient { if !strings.HasSuffix(baseURL, "/chat/completions") { cfg.URL = baseURL + "/chat/completions" } + + sdkBaseURL := strings.TrimSuffix(strings.TrimRight(cfg.URL, "/"), "/chat/completions") + return &OpenAIClient{ cfg: cfg, - client: &http.Client{ - Timeout: cfg.Timeout, - }, + sdk: openai.NewClient( + openaiopt.WithAPIKey(cfg.APIKey), + openaiopt.WithBaseURL(sdkBaseURL), + openaiopt.WithMaxRetries(5), + openaiopt.WithHeader("User-Agent", userAgent("")), + openaiopt.WithRequestTimeout(cfg.Timeout), + ), } } -// NewClient is kept as an alias for backward compatibility during transition. -func NewClient(cfg ClientConfig) *OpenAIClient { - return NewOpenAIClient(cfg) -} - // ChatRequest represents the payload for a chat completion call. type ChatRequest struct { Model string `json:"model"` Messages []Message `json:"messages"` Tools []ToolDef `json:"tools,omitempty"` - Stream bool `json:"stream,omitempty"` Temperature *float64 `json:"temperature,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` } -// Completions sends a chat completion request and returns the parsed response. -func (c *OpenAIClient) Completions(req ChatRequest) (*ChatResponse, error) { - return c.CompletionsWithCtx(context.Background(), req) -} - // CompletionsWithCtx sends a chat completion request with context support for cancellation and timeout. func (c *OpenAIClient) CompletionsWithCtx(ctx context.Context, req ChatRequest) (*ChatResponse, error) { model := req.Model @@ -329,163 +317,156 @@ func (c *OpenAIClient) CompletionsWithCtx(ctx context.Context, req ChatRequest) model = c.cfg.Model } - var result *ChatResponse - err := c.withRetryCtx(ctx, func() error { - resp, err := c.doRequestCtx(ctx, model, req) - if err != nil { - return err + params := c.buildOpenAIParams(model, req) + + var opts []openaiopt.RequestOption + for k, v := range c.cfg.ExtraBody { + opts = append(opts, openaiopt.WithJSONSet(k, v)) + } + + sdkResp, err := c.sdk.Chat.Completions.New(ctx, params, opts...) + if err != nil { + return nil, err + } + + return c.mapOpenAIResponse(sdkResp), nil +} + +// buildOpenAIParams converts the shared ChatRequest into OpenAI SDK parameters. +func (c *OpenAIClient) buildOpenAIParams(model string, req ChatRequest) openai.ChatCompletionNewParams { + var messages []openai.ChatCompletionMessageParamUnion + + for _, msg := range req.Messages { + content := msg.ExtractText() + + switch msg.Role { + case "system": + messages = append(messages, openai.SystemMessage(content)) + case "user": + messages = append(messages, openai.UserMessage(content)) + case "tool": + messages = append(messages, openai.ToolMessage(content, msg.ToolCallID)) + case "assistant": + if len(msg.ToolCalls) == 0 { + messages = append(messages, openai.AssistantMessage(content)) + } else { + asst := openai.ChatCompletionAssistantMessageParam{} + if content != "" { + asst.Content.OfString = openai.String(content) + } + for _, tc := range msg.ToolCalls { + asst.ToolCalls = append(asst.ToolCalls, openai.ChatCompletionMessageToolCallUnionParam{ + OfFunction: &openai.ChatCompletionMessageFunctionToolCallParam{ + ID: tc.ID, + Function: openai.ChatCompletionMessageFunctionToolCallFunctionParam{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + }, + }) + } + messages = append(messages, openai.ChatCompletionMessageParamUnion{OfAssistant: &asst}) + } + default: + messages = append(messages, openai.UserMessage(content)) } - result = resp - return nil - }) - return result, err -} + } -// GeneralRequest sends a simple chat request without or with optional tool calls. -func (c *OpenAIClient) GeneralRequest(messages []Message, model string, tools []ToolDef) (*ChatResponse, error) { - return c.GeneralRequestWithCtx(context.Background(), messages, model, tools) -} + var tools []openai.ChatCompletionToolUnionParam + for _, t := range req.Tools { + tools = append(tools, openai.ChatCompletionFunctionTool(shared.FunctionDefinitionParam{ + Name: t.Function.Name, + Description: openai.String(t.Function.Description), + Parameters: shared.FunctionParameters(t.Function.Parameters), + })) + } -// GeneralRequestWithCtx sends a simple chat request with context support. -func (c *OpenAIClient) GeneralRequestWithCtx(ctx context.Context, messages []Message, model string, tools []ToolDef) (*ChatResponse, error) { - return c.CompletionsWithCtx(ctx, ChatRequest{ - Model: model, + params := openai.ChatCompletionNewParams{ + Model: shared.ChatModel(model), Messages: messages, - Tools: tools, - }) -} - -// StreamCompletion initiates a streaming chat completion. The callback is invoked per chunk. -func (c *OpenAIClient) StreamCompletion(req ChatRequest, cb func(chunk []byte) error) error { - return c.StreamCompletionWithCtx(context.Background(), req, cb) -} - -// StreamCompletionWithCtx initiates a streaming chat completion with context support for cancellation and timeout. -func (c *OpenAIClient) StreamCompletionWithCtx(ctx context.Context, req ChatRequest, cb func(chunk []byte) error) error { - req.Stream = true - - model := req.Model - if model == "" { - model = c.cfg.Model } - return c.withRetryCtx(ctx, func() error { - body := make(map[string]any) - b, _ := json.Marshal(req) - json.Unmarshal(b, &body) - body["model"] = model - for k, v := range c.cfg.ExtraBody { - body[k] = v - } + if len(tools) > 0 { + params.Tools = tools + } + if req.MaxTokens > 0 { + params.MaxTokens = openai.Int(int64(req.MaxTokens)) + } + if req.Temperature != nil { + params.Temperature = openai.Float(*req.Temperature) + } - payload, _ := json.Marshal(body) - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.URL, bytes.NewReader(payload)) - if err != nil { - return fmt.Errorf("create request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) - httpReq.Header.Set("Accept", "text/event-stream") - httpReq.Header.Set("User-Agent", userAgent("")) + return params +} - resp, err := c.client.Do(httpReq) - if err != nil { - return fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() +// mapOpenAIResponse converts the SDK response into ChatResponse. +func (c *OpenAIClient) mapOpenAIResponse(sdkResp *openai.ChatCompletion) *ChatResponse { + rawJSON := sdkResp.RawJSON() - if isRetryableStatus(resp.StatusCode) { - bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API error %d: %s", resp.StatusCode, string(bodyBytes)) - } - if resp.StatusCode >= 400 { - bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API error %d: %s (non-retryable)", resp.StatusCode, string(bodyBytes)) - } - - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - line := scanner.Text() - if !strings.HasPrefix(line, "data: ") { - continue - } - data := strings.TrimPrefix(line, "data: ") - if data == "[DONE]" { - break - } - if err := cb([]byte(data)); err != nil { - return err + usage := resolveUsage([]byte(rawJSON)) + if usage == nil { + u := sdkResp.Usage + if u.PromptTokens > 0 || u.CompletionTokens > 0 { + usage = &UsageInfo{ + PromptTokens: u.PromptTokens, + CompletionTokens: u.CompletionTokens, + TotalTokens: u.TotalTokens, } } - return scanner.Err() - }) -} - -// doRequest builds and sends a non-streaming completion request, returning the parsed response. -func (c *OpenAIClient) doRequest(model string, req ChatRequest) (*ChatResponse, error) { - return c.doRequestCtx(context.Background(), model, req) -} - -// doRequestCtx builds and sends a non-streaming completion request with context support. -func (c *OpenAIClient) doRequestCtx(ctx context.Context, model string, req ChatRequest) (*ChatResponse, error) { - if model == "" { - model = c.cfg.Model - } - req.Model = model - payload, err := mergeExtraBody(req, c.cfg.ExtraBody) - if err != nil { - return nil, fmt.Errorf("marshal request body: %w", err) - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.URL, bytes.NewReader(payload)) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) - httpReq.Header.Set("User-Agent", userAgent("")) - - resp, err := c.client.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response body: %w", err) } - if resp.StatusCode >= 400 { - detail := extractErrorMessage(bodyBytes) - return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, detail) - } + var choices []Choice + for _, ch := range sdkResp.Choices { + var toolCalls []ToolCall + for _, tc := range ch.Message.ToolCalls { + toolCalls = append(toolCalls, ToolCall{ + ID: tc.ID, + Type: tc.Type, + Function: FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + }) + } - var apiResp struct { - ID string `json:"id"` - Model string `json:"model"` - Choices []Choice `json:"choices"` - } - if err := json.Unmarshal(bodyBytes, &apiResp); err != nil { - return nil, fmt.Errorf("decode response: %w", err) + content := ch.Message.Content + var contentPtr *string + if content != "" { + contentPtr = &content + } + + var reasoningContent string + if extra, ok := ch.Message.JSON.ExtraFields["reasoning_content"]; ok && extra.Valid() { + if err := json.Unmarshal([]byte(extra.Raw()), &reasoningContent); err != nil { + reasoningContent = extra.Raw() + } + } + + choices = append(choices, Choice{ + Message: ResponseMessage{ + Role: "assistant", + Content: contentPtr, + ReasoningContent: reasoningContent, + ToolCalls: toolCalls, + }, + FinishReason: ch.FinishReason, + }) } return &ChatResponse{ - ID: apiResp.ID, - Model: apiResp.Model, - Choices: apiResp.Choices, - Headers: resp.Header, - Usage: resolveUsage(bodyBytes), - }, nil + ID: sdkResp.ID, + Model: sdkResp.Model, + Choices: choices, + Usage: usage, + } } // --- AnthropicClient --- -const anthropicVersion = "2023-06-01" - -// AnthropicClient implements the Anthropic Messages API. +// AnthropicClient implements the Anthropic Messages API using the official SDK. type AnthropicClient struct { - cfg ClientConfig - client *http.Client + cfg ClientConfig + sdk anthropic.Client } // NewAnthropicClient creates a new Anthropic Messages API client. @@ -499,19 +480,21 @@ func NewAnthropicClient(cfg ClientConfig) *AnthropicClient { cfg.URL = baseURL + "/v1/messages" } } + + sdkBaseURL := strings.TrimSuffix(strings.TrimRight(cfg.URL, "/"), "/v1/messages") + return &AnthropicClient{ cfg: cfg, - client: &http.Client{ - Timeout: cfg.Timeout, - }, + sdk: anthropic.NewClient( + option.WithAuthToken(cfg.APIKey), + option.WithBaseURL(sdkBaseURL), + option.WithMaxRetries(5), + option.WithHeader("User-Agent", userAgent("claude")), + option.WithRequestTimeout(cfg.Timeout), + ), } } -// Completions sends a chat completion request and returns the parsed response. -func (c *AnthropicClient) Completions(req ChatRequest) (*ChatResponse, error) { - return c.CompletionsWithCtx(context.Background(), req) -} - // CompletionsWithCtx sends a chat completion request with context support. func (c *AnthropicClient) CompletionsWithCtx(ctx context.Context, req ChatRequest) (*ChatResponse, error) { model := req.Model @@ -519,196 +502,40 @@ func (c *AnthropicClient) CompletionsWithCtx(ctx context.Context, req ChatReques model = c.cfg.Model } - var result *ChatResponse - err := c.withRetryCtx(ctx, func() error { - resp, err := c.doRequestCtx(ctx, model, req) - if err != nil { - return err - } - result = resp - return nil - }) - return result, err -} + params := c.buildAnthropicParams(model, req) -// StreamCompletion initiates a streaming chat completion using SSE. The callback -// is invoked per chunk with raw JSON data stripped of the "data: " prefix. -func (c *AnthropicClient) StreamCompletion(req ChatRequest, cb func(chunk []byte) error) error { - return c.StreamCompletionWithCtx(context.Background(), req, cb) -} - -// StreamCompletionWithCtx initiates a streaming chat completion with context support for cancellation and timeout. -func (c *AnthropicClient) StreamCompletionWithCtx(ctx context.Context, req ChatRequest, cb func(chunk []byte) error) error { - req.Stream = true - - model := req.Model - if model == "" { - model = c.cfg.Model + var opts []option.RequestOption + for k, v := range c.cfg.ExtraBody { + opts = append(opts, option.WithJSONSet(k, v)) } - return c.withRetryCtx(ctx, func() error { - body := c.buildRequestBody(model, req) - body.Stream = true - - payload, err := mergeExtraBody(body, c.cfg.ExtraBody) - if err != nil { - return fmt.Errorf("marshal request body: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.URL, bytes.NewReader(payload)) - if err != nil { - return fmt.Errorf("create request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("x-api-key", c.cfg.APIKey) - httpReq.Header.Set("anthropic-version", anthropicVersion) - httpReq.Header.Set("User-Agent", userAgent("claude")) - - resp, err := c.client.Do(httpReq) - if err != nil { - return fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if isRetryableStatus(resp.StatusCode) { - bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API error %d: %s", resp.StatusCode, string(bodyBytes)) - } - if resp.StatusCode >= 400 { - bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API error %d: %s (non-retryable)", resp.StatusCode, string(bodyBytes)) - } - - scanner := bufio.NewScanner(resp.Body) - var eventType string - - for scanner.Scan() { - line := scanner.Text() - - if strings.HasPrefix(line, "event: ") { - eventType = strings.TrimPrefix(line, "event: ") - continue - } - - if !strings.HasPrefix(line, "data: ") { - continue - } - - data := strings.TrimPrefix(line, "data: ") - if data == "" { - continue - } - - if eventType == "message_stop" { - break - } - - if err := cb([]byte(data)); err != nil { - return err - } - } - return scanner.Err() - }) -} - -// anthropicRequest is the request body for Anthropic Messages API. -type anthropicRequest struct { - Model string `json:"model"` - MaxTokens int `json:"max_tokens"` - System string `json:"system,omitempty"` - Messages []anthroMessage `json:"messages"` - Tools []anthroTool `json:"tools,omitempty"` - Stream bool `json:"stream,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` -} - -type anthroMessage struct { - Role string `json:"role"` - Content any `json:"content"` // string or []interface{} -} - -// anthropicToolUseBlock represents a tool_use content block in Anthropic's Messages API. -type anthropicToolUseBlock struct { - Type string `json:"type"` // "tool_use" - ID string `json:"id"` // tool use ID - Name string `json:"name"` // function name - Input map[string]any `json:"input"` // function arguments (parsed as object) -} - -type anthroTool struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]any `json:"input_schema"` -} - -// doRequestCtx builds and sends an Anthropic Messages API request. -func (c *AnthropicClient) doRequestCtx(ctx context.Context, model string, req ChatRequest) (*ChatResponse, error) { - if model == "" { - model = c.cfg.Model - } - - body := c.buildRequestBody(model, req) - payload, err := mergeExtraBody(body, c.cfg.ExtraBody) + sdkResp, err := c.sdk.Messages.New(ctx, params, opts...) if err != nil { - return nil, fmt.Errorf("marshal request body: %w", err) + return nil, err } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.URL, bytes.NewReader(payload)) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("x-api-key", c.cfg.APIKey) - httpReq.Header.Set("anthropic-version", anthropicVersion) - httpReq.Header.Set("User-Agent", userAgent("claude")) - - resp, err := c.client.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response body: %w", err) - } - - if resp.StatusCode >= 400 { - detail := extractErrorMessage(bodyBytes) - return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, detail) - } - - chatResp, err := c.parseResponse(bodyBytes, resp.Header) - if err != nil { - return nil, fmt.Errorf("decode response: %w", err) - } - return chatResp, nil + return c.mapAnthropicResponse(sdkResp), nil } -// buildRequestBody converts the shared ChatRequest into Anthropic format. -func (c *AnthropicClient) buildRequestBody(model string, req ChatRequest) anthropicRequest { - messages := make([]anthroMessage, 0, len(req.Messages)) - var systemMsg string - - var pendingToolResults []Message // collect consecutive tool messages +// buildAnthropicParams converts the shared ChatRequest into Anthropic SDK parameters. +func (c *AnthropicClient) buildAnthropicParams(model string, req ChatRequest) anthropic.MessageNewParams { + var systemBlocks []anthropic.TextBlockParam + var messages []anthropic.MessageParam + var pendingToolResults []Message flushToolResults := func() { if len(pendingToolResults) == 0 { return } - // Merge all pending tool results into a single user message - var blocks []interface{} + var blocks []anthropic.ContentBlockParamUnion for _, tr := range pendingToolResults { - blocks = append(blocks, ContentBlock{ - Type: "tool_result", - ToolUseID: tr.ToolCallID, - Content: []ContentBlock{{ - Type: "text", - Text: fmt.Sprintf("%v", tr.Content), - }}, - }) + blocks = append(blocks, anthropic.NewToolResultBlock( + tr.ToolCallID, + fmt.Sprintf("%v", tr.Content), + false, + )) } - messages = append(messages, anthroMessage{Role: "user", Content: blocks}) + messages = append(messages, anthropic.NewUserMessage(blocks...)) pendingToolResults = nil } @@ -716,149 +543,127 @@ func (c *AnthropicClient) buildRequestBody(model string, req ChatRequest) anthro switch msg.Role { case "system": if s, ok := msg.Content.(string); ok { - systemMsg = s + systemBlocks = append(systemBlocks, anthropic.TextBlockParam{Text: s}) } flushToolResults() case "tool": pendingToolResults = append(pendingToolResults, msg) case "assistant": flushToolResults() - // Build Anthropic content blocks from text + tool calls - var blocks []interface{} + var blocks []anthropic.ContentBlockParamUnion if s, ok := msg.Content.(string); ok && s != "" { - blocks = append(blocks, ContentBlock{Type: "text", Text: s}) + blocks = append(blocks, anthropic.NewTextBlock(s)) } for _, tc := range msg.ToolCalls { argsMap := map[string]any{} if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &argsMap); err != nil { - fmt.Fprintf(stdout.Writer(), "[llm] WARNING: failed to parse tool call arguments JSON for %q: %v\n", tc.ID, err) - } + json.Unmarshal([]byte(tc.Function.Arguments), &argsMap) } - blocks = append(blocks, anthropicToolUseBlock{ - Type: "tool_use", - ID: tc.ID, - Name: tc.Function.Name, - Input: argsMap, - }) + blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, argsMap, tc.Function.Name)) } if len(blocks) > 0 { - messages = append(messages, anthroMessage{Role: "assistant", Content: blocks}) + messages = append(messages, anthropic.NewAssistantMessage(blocks...)) } else { s, _ := msg.Content.(string) - messages = append(messages, anthroMessage{Role: "assistant", Content: s}) + messages = append(messages, anthropic.NewAssistantMessage(anthropic.NewTextBlock(s))) } default: - // user or other roles: flush tool results first flushToolResults() - content := msg.Content - if blkArr, ok := content.([]ContentBlock); ok { - converted := make([]ContentBlock, len(blkArr)) - for i, b := range blkArr { - converted[i] = ContentBlock{ - Type: b.Type, - Text: b.Text, - ToolUseID: b.ToolUseID, - Content: b.Content, + switch content := msg.Content.(type) { + case string: + messages = append(messages, anthropic.NewUserMessage(anthropic.NewTextBlock(content))) + case []ContentBlock: + var blocks []anthropic.ContentBlockParamUnion + for _, b := range content { + if b.Type == "tool_result" { + blocks = append(blocks, anthropic.NewToolResultBlock(b.ToolUseID, extractBlockText(b), false)) + } else { + blocks = append(blocks, anthropic.NewTextBlock(b.Text)) } } - content = converted + if len(blocks) > 0 { + messages = append(messages, anthropic.NewUserMessage(blocks...)) + } } - messages = append(messages, anthroMessage{Role: msg.Role, Content: content}) } } - flushToolResults() // flush any remaining tool results at the end + flushToolResults() - tools := make([]anthroTool, 0, len(req.Tools)) + var tools []anthropic.ToolUnionParam for _, t := range req.Tools { - tools = append(tools, anthroTool{ - Name: t.Function.Name, - Description: t.Function.Description, - InputSchema: t.Function.Parameters, + tools = append(tools, anthropic.ToolUnionParam{ + OfTool: &anthropic.ToolParam{ + Name: t.Function.Name, + Description: anthropic.String(t.Function.Description), + InputSchema: buildToolInputSchema(t.Function.Parameters), + }, }) } - maxTokens := req.MaxTokens + maxTokens := int64(req.MaxTokens) if maxTokens <= 0 { - maxTokens = 8192 // Anthropic default + maxTokens = 8192 } - return anthropicRequest{ - Model: model, - MaxTokens: maxTokens, - System: systemMsg, - Messages: messages, - Tools: tools, - Stream: false, - Temperature: req.Temperature, + params := anthropic.MessageNewParams{ + Model: anthropic.Model(model), + MaxTokens: maxTokens, + Messages: messages, } + + if len(systemBlocks) > 0 { + params.System = systemBlocks + } + if len(tools) > 0 { + params.Tools = tools + } + if req.Temperature != nil { + params.Temperature = anthropic.Float(*req.Temperature) + } + + return params } -func mergeExtraBody(base any, extraBody map[string]any) ([]byte, error) { - if len(extraBody) == 0 { - return json.Marshal(base) +func buildToolInputSchema(params map[string]any) anthropic.ToolInputSchemaParam { + schema := anthropic.ToolInputSchemaParam{} + if props, ok := params["properties"]; ok { + schema.Properties = props } - b, err := json.Marshal(base) - if err != nil { - return nil, err + if req, ok := params["required"].([]any); ok { + for _, r := range req { + if s, ok := r.(string); ok { + schema.Required = append(schema.Required, s) + } + } } - var m map[string]any - if err := json.Unmarshal(b, &m); err != nil { - return nil, err + for k, v := range params { + if k == "type" || k == "properties" || k == "required" { + continue + } + if schema.ExtraFields == nil { + schema.ExtraFields = make(map[string]any) + } + schema.ExtraFields[k] = v } - for k, v := range extraBody { - m[k] = v - } - return json.Marshal(m) + return schema } -// parseResponse converts Anthropic JSON response into ChatResponse. -func (c *AnthropicClient) parseResponse(body []byte, headers http.Header) (*ChatResponse, error) { - type contentBlockResp struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input any `json:"input,omitempty"` - } - - type anthropicUsageRaw struct { - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheReadInputTokens int64 `json:"cache_read_input_tokens"` - CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` - } - - var resp struct { - ID string `json:"id"` - Model string `json:"model"` - Type string `json:"type"` - Role string `json:"role"` - Content []contentBlockResp `json:"content"` - Usage anthropicUsageRaw `json:"usage"` - StopReason string `json:"stop_reason,omitempty"` - } - - if err := json.Unmarshal(body, &resp); err != nil { - return nil, err - } - - // Build the response message from content blocks. +// mapAnthropicResponse converts the SDK response into ChatResponse. +func (c *AnthropicClient) mapAnthropicResponse(sdkResp *anthropic.Message) *ChatResponse { var textParts []string var toolCalls []ToolCall - for _, block := range resp.Content { + for _, block := range sdkResp.Content { switch block.Type { case "text": textParts = append(textParts, block.Text) case "tool_use": - argsJSON, _ := json.Marshal(block.Input) toolCalls = append(toolCalls, ToolCall{ ID: block.ID, Type: "function", Function: FunctionCall{ Name: block.Name, - Arguments: string(argsJSON), + Arguments: string(block.Input), }, }) } @@ -870,13 +675,14 @@ func (c *AnthropicClient) parseResponse(body []byte, headers http.Header) (*Chat contentStr = &s } - finishReason := resp.StopReason + finishReason := string(sdkResp.StopReason) if finishReason == "" { finishReason = "stop" } var usage *UsageInfo - if u := resp.Usage; u.InputTokens > 0 || u.OutputTokens > 0 { + u := sdkResp.Usage + if u.InputTokens > 0 || u.OutputTokens > 0 { usage = &UsageInfo{ PromptTokens: u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens, CompletionTokens: u.OutputTokens, @@ -884,114 +690,23 @@ func (c *AnthropicClient) parseResponse(body []byte, headers http.Header) (*Chat CacheWriteTokens: u.CacheCreationInputTokens, } usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } else { + usage = resolveUsage([]byte(sdkResp.RawJSON())) } return &ChatResponse{ - ID: resp.ID, - Model: resp.Model, + ID: sdkResp.ID, + Model: string(sdkResp.Model), Choices: []Choice{{ Message: ResponseMessage{ - Role: resp.Role, + Role: "assistant", Content: contentStr, ToolCalls: toolCalls, }, FinishReason: finishReason, }}, - Headers: headers, - Usage: usage, - }, nil -} - -// --- Retry logic --- - -func retryWithCtx(ctx context.Context, fn func() error) error { - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled: %w", ctx.Err()) - default: - } - - lastErr = fn() - if lastErr == nil { - return nil - } - - if !isRetryable(lastErr) { - return lastErr - } - - if attempt < maxRetries { - sleepWithBackoff(attempt) - } + Usage: usage, } - return fmt.Errorf("request failed after %d retries: %w", maxRetries, lastErr) -} - -func (c *OpenAIClient) withRetry(fn func() error) error { - return retryWithCtx(context.Background(), fn) -} - -func (c *OpenAIClient) withRetryCtx(ctx context.Context, fn func() error) error { - return retryWithCtx(ctx, fn) -} - -func (c *AnthropicClient) withRetry(fn func() error) error { - return retryWithCtx(context.Background(), fn) -} - -func (c *AnthropicClient) withRetryCtx(ctx context.Context, fn func() error) error { - return retryWithCtx(ctx, fn) -} - -// isRetryable determines whether an error is transient and worth retrying. -func isRetryable(err error) bool { - msg := err.Error() - // 429 (rate limit) and 5xx server errors are retryable. - if strings.Contains(msg, "API error 429:") { - return true - } - for code := 500; code <= 599; code++ { - if strings.Contains(msg, fmt.Sprintf("API error %d:", code)) { - return true - } - } - // Network-level errors (timeout, connection refused, DNS failure, etc.) are retryable. - if strings.Contains(msg, "request failed:") || - strings.Contains(msg, "connection refused") || - strings.Contains(msg, "no such host") || - strings.Contains(msg, "i/o timeout") || - strings.Contains(msg, "EOF") { - return true - } - return false -} - -// isRetryableStatus returns true for HTTP status codes that should trigger a retry. -func isRetryableStatus(status int) bool { - return status == 429 || (status >= 500 && status <= 599) -} - -// sleepWithBackoff sleeps for baseDelay * 2^attempt + jitter, capped at 60s. -// Jitter spreads retries randomly within ±50% of the computed delay. -func sleepWithBackoff(attempt int) { - const ( - baseDelay = 1 * time.Second - maxDelay = 60 * time.Second - ) - - delay := baseDelay << uint(min(attempt, 6)) // 1s, 2s, 4s, 8s, 16s, 32s, 64s→capped - if delay > maxDelay { - delay = maxDelay - } - - // Add random jitter: [delay*0.5, delay*1.5] - jitter := time.Duration(rand.Int63n(int64(delay))) - delay/2 - delay += jitter - - fmt.Fprintf(stdout.Writer(), "[llm] Retrying in %v (attempt info)... \n", delay) - time.Sleep(delay) } // stripThinkTags removes reasoning wrapper tags from content. @@ -1003,40 +718,3 @@ func stripThinkTags(s string) string { s = strings.ReplaceAll(s, string(closeBytes), "") return s } - -// extractErrorMessage attempts to pull a human-readable error message from -// a JSON API error response body. Falls back to truncating the raw body if -// the structure is not recognised or decoding fails. -func extractErrorMessage(body []byte) string { - type openAIError struct { - Error struct { - Message string `json:"message"` - } `json:"error"` - } - type anthropicError struct { - Type string `json:"type"` - Error struct { - Message string `json:"message"` - } `json:"error"` - } - - if len(body) == 0 { - return "(empty body)" - } - - var oe openAIError - if err := json.Unmarshal(body, &oe); err == nil && oe.Error.Message != "" { - return oe.Error.Message - } - var ae anthropicError - if err := json.Unmarshal(body, &ae); err == nil && ae.Error.Message != "" { - return ae.Error.Message - } - - // Truncate raw body to avoid excessively noisy errors. - bodyText := string(body) - if len(bodyText) > 512 { - bodyText = bodyText[:512] + "... (truncated)" - } - return bodyText -}